feat: add mulTmux collaborative terminal tool
Add mulTmux as an integrated workspace in canvas-website project: - Node.js/TypeScript backend with tmux session management - CLI client with blessed-based terminal UI - WebSocket-based real-time collaboration - Token-based authentication with invite links - Session management (create, join, list) - PM2 deployment scripts for AI server - nginx reverse proxy configuration - Workspace integration with npm scripts Usage: - npm run multmux:build - Build server and CLI - npm run multmux:start - Start production server - multmux create <name> - Create collaborative session - multmux join <token> - Join existing session See MULTMUX_INTEGRATION.md for full documentation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1e55f3a576
commit
1aec51e97b
|
|
@ -0,0 +1,232 @@
|
|||
# mulTmux Integration
|
||||
|
||||
mulTmux is now integrated into the canvas-website project as a collaborative terminal tool. This allows multiple developers to work together in the same terminal session.
|
||||
|
||||
## Installation
|
||||
|
||||
From the root of the canvas-website project:
|
||||
|
||||
```bash
|
||||
# Install all dependencies including mulTmux packages
|
||||
npm run multmux:install
|
||||
|
||||
# Build mulTmux packages
|
||||
npm run multmux:build
|
||||
```
|
||||
|
||||
## Available Commands
|
||||
|
||||
All commands are run from the **root** of the canvas-website project:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run multmux:install` | Install mulTmux dependencies |
|
||||
| `npm run multmux:build` | Build server and CLI packages |
|
||||
| `npm run multmux:dev:server` | Run server in development mode |
|
||||
| `npm run multmux:dev:cli` | Run CLI in development mode |
|
||||
| `npm run multmux:start` | Start the production server |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Build mulTmux
|
||||
|
||||
```bash
|
||||
npm run multmux:build
|
||||
```
|
||||
|
||||
### 2. Start the Server Locally (for testing)
|
||||
|
||||
```bash
|
||||
npm run multmux:start
|
||||
```
|
||||
|
||||
Server will be available at:
|
||||
- HTTP API: `http://localhost:3000`
|
||||
- WebSocket: `ws://localhost:3001`
|
||||
|
||||
### 3. Install CLI Globally
|
||||
|
||||
```bash
|
||||
cd multmux/packages/cli
|
||||
npm link
|
||||
```
|
||||
|
||||
Now you can use the `multmux` command anywhere!
|
||||
|
||||
### 4. Create a Session
|
||||
|
||||
```bash
|
||||
# Local testing
|
||||
multmux create my-session
|
||||
|
||||
# Or specify your AI server (when deployed)
|
||||
multmux create my-session --server http://your-ai-server:3000
|
||||
```
|
||||
|
||||
### 5. Join from Another Terminal
|
||||
|
||||
```bash
|
||||
multmux join <token-from-above> --server ws://your-ai-server:3001
|
||||
```
|
||||
|
||||
## Deploying to AI Server
|
||||
|
||||
### Option 1: Using the Deploy Script
|
||||
|
||||
```bash
|
||||
cd multmux
|
||||
./infrastructure/deploy.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Install system dependencies (tmux, Node.js)
|
||||
- Build the project
|
||||
- Set up PM2 for process management
|
||||
- Start the server
|
||||
|
||||
### Option 2: Manual Deployment
|
||||
|
||||
1. **SSH to your AI server**
|
||||
```bash
|
||||
ssh your-ai-server
|
||||
```
|
||||
|
||||
2. **Clone or copy the project**
|
||||
```bash
|
||||
git clone <your-repo>
|
||||
cd canvas-website
|
||||
git checkout mulTmux-webtree
|
||||
```
|
||||
|
||||
3. **Install and build**
|
||||
```bash
|
||||
npm install
|
||||
npm run multmux:build
|
||||
```
|
||||
|
||||
4. **Start with PM2**
|
||||
```bash
|
||||
cd multmux
|
||||
npm install -g pm2
|
||||
pm2 start packages/server/dist/index.js --name multmux-server
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
canvas-website/
|
||||
├── multmux/
|
||||
│ ├── packages/
|
||||
│ │ ├── server/ # Backend (Node.js + tmux)
|
||||
│ │ └── cli/ # Command-line client
|
||||
│ ├── infrastructure/
|
||||
│ │ ├── deploy.sh # Auto-deployment script
|
||||
│ │ └── nginx.conf # Reverse proxy config
|
||||
│ └── README.md # Full documentation
|
||||
├── package.json # Now includes workspace config
|
||||
└── MULTMUX_INTEGRATION.md # This file
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Collaborative Coding Session
|
||||
|
||||
```bash
|
||||
# Developer 1: Create session in project directory
|
||||
cd /path/to/project
|
||||
multmux create coding-session --repo $(pwd)
|
||||
|
||||
# Developer 2: Join and start coding together
|
||||
multmux join <token>
|
||||
|
||||
# Both can now type in the same terminal!
|
||||
```
|
||||
|
||||
### Debugging Together
|
||||
|
||||
```bash
|
||||
# Create a session for debugging
|
||||
multmux create debug-auth-issue
|
||||
|
||||
# Share token with teammate
|
||||
# Both can run commands, check logs, etc.
|
||||
```
|
||||
|
||||
### List Active Sessions
|
||||
|
||||
```bash
|
||||
multmux list
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can customize ports by setting environment variables:
|
||||
|
||||
```bash
|
||||
export PORT=3000 # HTTP API port
|
||||
export WS_PORT=3001 # WebSocket port
|
||||
```
|
||||
|
||||
### Token Expiration
|
||||
|
||||
Default: 60 minutes. To change, edit `/home/jeffe/Github/canvas-website/multmux/packages/server/src/managers/TokenManager.ts:11`
|
||||
|
||||
### Session Cleanup
|
||||
|
||||
Sessions auto-cleanup when all users disconnect. To change this behavior, edit `/home/jeffe/Github/canvas-website/multmux/packages/server/src/managers/SessionManager.ts:64`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Command not found: multmux"
|
||||
|
||||
Run `npm link` from the CLI package:
|
||||
```bash
|
||||
cd multmux/packages/cli
|
||||
npm link
|
||||
```
|
||||
|
||||
### "Connection refused"
|
||||
|
||||
1. Check server is running:
|
||||
```bash
|
||||
pm2 status
|
||||
```
|
||||
|
||||
2. Check ports are available:
|
||||
```bash
|
||||
netstat -tlnp | grep -E '3000|3001'
|
||||
```
|
||||
|
||||
3. Check logs:
|
||||
```bash
|
||||
pm2 logs multmux-server
|
||||
```
|
||||
|
||||
### Token Expired
|
||||
|
||||
Generate a new token:
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/sessions/<session-id>/tokens \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"expiresInMinutes": 60}'
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Tokens expire after 60 minutes
|
||||
- Sessions are isolated per tmux instance
|
||||
- All input is validated on the server
|
||||
- Use nginx + SSL for production deployments
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test locally first**: Run `npm run multmux:start` and try creating/joining sessions
|
||||
2. **Deploy to AI server**: Use `./infrastructure/deploy.sh`
|
||||
3. **Set up nginx**: Copy config from `infrastructure/nginx.conf` for SSL/reverse proxy
|
||||
4. **Share with team**: Send them tokens to collaborate!
|
||||
|
||||
For full documentation, see `multmux/README.md`.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pm2.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# PM2
|
||||
ecosystem.config.js
|
||||
.pm2/
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
# mulTmux
|
||||
|
||||
A collaborative terminal tool that lets multiple users interact with the same tmux session in real-time.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Collaboration**: Multiple users can connect to the same terminal session
|
||||
- **tmux Backend**: Leverages tmux for robust terminal multiplexing
|
||||
- **Token-based Auth**: Secure invite links with expiration
|
||||
- **Presence Indicators**: See who's connected to your session
|
||||
- **Low Resource Usage**: ~200-300MB RAM for typical usage
|
||||
- **Easy Deployment**: Works alongside existing services on your server
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐
|
||||
│ Client │ ──── WebSocket ────────> │ Server │
|
||||
│ (CLI) │ (token auth) │ │
|
||||
└─────────────┘ │ ┌────────────┐ │
|
||||
│ │ Node.js │ │
|
||||
┌─────────────┐ │ │ Backend │ │
|
||||
│ Client 2 │ ──── Invite Link ──────> │ └─────┬──────┘ │
|
||||
│ (CLI) │ │ │ │
|
||||
└─────────────┘ │ ┌─────▼──────┐ │
|
||||
│ │ tmux │ │
|
||||
│ │ Sessions │ │
|
||||
│ └────────────┘ │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Server Setup
|
||||
|
||||
1. **Deploy to your AI server:**
|
||||
```bash
|
||||
cd multmux
|
||||
chmod +x infrastructure/deploy.sh
|
||||
./infrastructure/deploy.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Install tmux if needed
|
||||
- Build the server
|
||||
- Set up PM2 for process management
|
||||
- Start the server
|
||||
|
||||
2. **(Optional) Set up nginx reverse proxy:**
|
||||
```bash
|
||||
sudo cp infrastructure/nginx.conf /etc/nginx/sites-available/multmux
|
||||
sudo ln -s /etc/nginx/sites-available/multmux /etc/nginx/sites-enabled/
|
||||
# Edit the file to set your domain
|
||||
sudo nano /etc/nginx/sites-available/multmux
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### CLI Installation
|
||||
|
||||
**On your local machine:**
|
||||
```bash
|
||||
cd multmux/packages/cli
|
||||
npm install
|
||||
npm run build
|
||||
npm link # Installs 'multmux' command globally
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Create a Session
|
||||
|
||||
```bash
|
||||
multmux create my-project --repo /path/to/repo
|
||||
```
|
||||
|
||||
This outputs an invite link like:
|
||||
```
|
||||
multmux join a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
```
|
||||
|
||||
### Join a Session
|
||||
|
||||
```bash
|
||||
multmux join a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
```
|
||||
|
||||
### List Active Sessions
|
||||
|
||||
```bash
|
||||
multmux list
|
||||
```
|
||||
|
||||
### Using a Remote Server
|
||||
|
||||
If your server is on a different machine:
|
||||
|
||||
```bash
|
||||
# Create session
|
||||
multmux create my-project --server http://your-server:3000
|
||||
|
||||
# Join session
|
||||
multmux join <token> --server ws://your-server:3001
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `multmux create <name>` | Create a new collaborative session |
|
||||
| `multmux join <token>` | Join an existing session |
|
||||
| `multmux list` | List all active sessions |
|
||||
|
||||
### Options
|
||||
|
||||
**create:**
|
||||
- `-s, --server <url>` - Server URL (default: http://localhost:3000)
|
||||
- `-r, --repo <path>` - Repository path to cd into
|
||||
|
||||
**join:**
|
||||
- `-s, --server <url>` - WebSocket server URL (default: ws://localhost:3001)
|
||||
|
||||
**list:**
|
||||
- `-s, --server <url>` - Server URL (default: http://localhost:3000)
|
||||
|
||||
## Server Management
|
||||
|
||||
### PM2 Commands
|
||||
|
||||
```bash
|
||||
pm2 status # Check server status
|
||||
pm2 logs multmux-server # View server logs
|
||||
pm2 restart multmux-server # Restart server
|
||||
pm2 stop multmux-server # Stop server
|
||||
```
|
||||
|
||||
### Resource Usage
|
||||
|
||||
- **Idle**: ~100-150MB RAM
|
||||
- **Per session**: ~5-10MB RAM
|
||||
- **Per user**: ~1-2MB RAM
|
||||
- **Typical usage**: 200-300MB RAM total
|
||||
|
||||
## API Reference
|
||||
|
||||
### HTTP API (default: port 3000)
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/sessions` | POST | Create a new session |
|
||||
| `/api/sessions` | GET | List active sessions |
|
||||
| `/api/sessions/:id` | GET | Get session info |
|
||||
| `/api/sessions/:id/tokens` | POST | Generate new invite token |
|
||||
| `/api/health` | GET | Health check |
|
||||
|
||||
### WebSocket (default: port 3001)
|
||||
|
||||
Connect with: `ws://localhost:3001?token=<your-token>`
|
||||
|
||||
**Message Types:**
|
||||
- `output` - Terminal output from server
|
||||
- `input` - User input to terminal
|
||||
- `resize` - Terminal resize event
|
||||
- `presence` - User join/leave notifications
|
||||
- `joined` - Connection confirmation
|
||||
|
||||
## Security
|
||||
|
||||
- **Token Expiration**: Invite tokens expire after 60 minutes (configurable)
|
||||
- **Session Isolation**: Each session runs in its own tmux instance
|
||||
- **Input Validation**: All terminal input is validated
|
||||
- **No Persistence**: Sessions are destroyed when all users leave
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server won't start
|
||||
|
||||
Check if ports are available:
|
||||
```bash
|
||||
netstat -tlnp | grep -E '3000|3001'
|
||||
```
|
||||
|
||||
### Can't connect to server
|
||||
|
||||
1. Check server is running: `pm2 status`
|
||||
2. Check logs: `pm2 logs multmux-server`
|
||||
3. Verify firewall allows ports 3000 and 3001
|
||||
|
||||
### Terminal not responding
|
||||
|
||||
1. Check WebSocket connection in browser console
|
||||
2. Verify token hasn't expired
|
||||
3. Restart session: `pm2 restart multmux-server`
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
multmux/
|
||||
├── packages/
|
||||
│ ├── server/ # Backend server
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── managers/ # Session & token management
|
||||
│ │ │ ├── websocket/ # WebSocket handler
|
||||
│ │ │ └── api/ # HTTP routes
|
||||
│ └── cli/ # CLI client
|
||||
│ ├── src/
|
||||
│ │ ├── commands/ # CLI commands
|
||||
│ │ ├── connection/ # WebSocket client
|
||||
│ │ └── ui/ # Terminal UI
|
||||
└── infrastructure/ # Deployment scripts
|
||||
```
|
||||
|
||||
### Running in Development
|
||||
|
||||
**Terminal 1 - Server:**
|
||||
```bash
|
||||
npm run dev:server
|
||||
```
|
||||
|
||||
**Terminal 2 - CLI:**
|
||||
```bash
|
||||
cd packages/cli
|
||||
npm run dev -- create test-session
|
||||
```
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
npm run build # Builds both packages
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions welcome! Please open an issue or PR.
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
#!/bin/bash
|
||||
|
||||
# mulTmux Deployment Script for AI Server
|
||||
# This script sets up mulTmux on your existing droplet
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 mulTmux Deployment Script"
|
||||
echo "============================"
|
||||
echo ""
|
||||
|
||||
# Check if tmux is installed
|
||||
if ! command -v tmux &> /dev/null; then
|
||||
echo "📦 Installing tmux..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y tmux
|
||||
else
|
||||
echo "✅ tmux is already installed"
|
||||
fi
|
||||
|
||||
# Check if Node.js is installed
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "📦 Installing Node.js..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
else
|
||||
echo "✅ Node.js is already installed ($(node --version))"
|
||||
fi
|
||||
|
||||
# Check if npm is installed
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo "❌ npm is not installed. Please install npm first."
|
||||
exit 1
|
||||
else
|
||||
echo "✅ npm is already installed ($(npm --version))"
|
||||
fi
|
||||
|
||||
# Build the server
|
||||
echo ""
|
||||
echo "🔨 Building mulTmux..."
|
||||
cd "$(dirname "$0")/.."
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
echo ""
|
||||
echo "📝 Setting up PM2 for process management..."
|
||||
if ! command -v pm2 &> /dev/null; then
|
||||
sudo npm install -g pm2
|
||||
fi
|
||||
|
||||
# Create PM2 ecosystem file
|
||||
cat > ecosystem.config.js << EOF
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'multmux-server',
|
||||
script: './packages/server/dist/index.js',
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
max_memory_restart: '500M',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3000,
|
||||
WS_PORT: 3001
|
||||
}
|
||||
}]
|
||||
};
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "🚀 Starting mulTmux server with PM2..."
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 save
|
||||
pm2 startup | tail -n 1 | bash || true
|
||||
|
||||
echo ""
|
||||
echo "✅ mulTmux deployed successfully!"
|
||||
echo ""
|
||||
echo "Server is running on:"
|
||||
echo " HTTP API: http://localhost:3000"
|
||||
echo " WebSocket: ws://localhost:3001"
|
||||
echo ""
|
||||
echo "Useful PM2 commands:"
|
||||
echo " pm2 status - Check server status"
|
||||
echo " pm2 logs multmux-server - View logs"
|
||||
echo " pm2 restart multmux-server - Restart server"
|
||||
echo " pm2 stop multmux-server - Stop server"
|
||||
echo ""
|
||||
echo "To install the CLI globally:"
|
||||
echo " cd packages/cli && npm link"
|
||||
echo ""
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# nginx configuration for mulTmux
|
||||
# Place this in /etc/nginx/sites-available/multmux
|
||||
# Then: sudo ln -s /etc/nginx/sites-available/multmux /etc/nginx/sites-enabled/
|
||||
|
||||
upstream multmux_api {
|
||||
server localhost:3000;
|
||||
}
|
||||
|
||||
upstream multmux_ws {
|
||||
server localhost:3001;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-server-domain.com; # Change this to your domain or IP
|
||||
|
||||
# HTTP API
|
||||
location /api {
|
||||
proxy_pass http://multmux_api;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
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
|
||||
location /ws {
|
||||
proxy_pass http://multmux_ws;
|
||||
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;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
|
||||
# Optional: SSL configuration (if using Let's Encrypt)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name your-server-domain.com;
|
||||
#
|
||||
# ssl_certificate /etc/letsencrypt/live/your-server-domain.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/your-server-domain.com/privkey.pem;
|
||||
#
|
||||
# # Same location blocks as above...
|
||||
# }
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "multmux",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Collaborative terminal tool with tmux backend",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build -ws",
|
||||
"dev:server": "npm run dev -w @multmux/server",
|
||||
"dev:cli": "npm run dev -w @multmux/cli",
|
||||
"start:server": "npm run start -w @multmux/server"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@multmux/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "mulTmux CLI - collaborative terminal client",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"multmux": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx src/index.ts",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^11.1.0",
|
||||
"ws": "^8.16.0",
|
||||
"blessed": "^0.1.81",
|
||||
"chalk": "^4.1.2",
|
||||
"ora": "^5.4.1",
|
||||
"node-fetch": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/blessed": "^0.1.25",
|
||||
"@types/node-fetch": "^2.6.9",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import fetch from 'node-fetch';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
export async function createSession(
|
||||
name: string,
|
||||
options: { server?: string; repo?: string }
|
||||
): Promise<void> {
|
||||
const serverUrl = options.server || 'http://localhost:3000';
|
||||
const spinner = ora('Creating session...').start();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
repoPath: options.repo,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create session: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
|
||||
spinner.succeed('Session created!');
|
||||
|
||||
console.log('');
|
||||
console.log(chalk.bold('Session Details:'));
|
||||
console.log(` Name: ${chalk.cyan(data.session.name)}`);
|
||||
console.log(` ID: ${chalk.gray(data.session.id)}`);
|
||||
console.log(` Created: ${new Date(data.session.createdAt).toLocaleString()}`);
|
||||
console.log('');
|
||||
console.log(chalk.bold('To join this session:'));
|
||||
console.log(chalk.green(` ${data.inviteUrl}`));
|
||||
console.log('');
|
||||
console.log(chalk.bold('Or share this token:'));
|
||||
console.log(` ${chalk.yellow(data.token)}`);
|
||||
console.log('');
|
||||
console.log(chalk.dim('Token expires in 60 minutes'));
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create session');
|
||||
console.error(chalk.red((error as Error).message));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import { WebSocketClient } from '../connection/WebSocketClient';
|
||||
import { TerminalUI } from '../ui/Terminal';
|
||||
|
||||
export async function joinSession(
|
||||
token: string,
|
||||
options: { server?: string }
|
||||
): Promise<void> {
|
||||
const serverUrl = options.server || 'ws://localhost:3001';
|
||||
const spinner = ora('Connecting to session...').start();
|
||||
|
||||
try {
|
||||
const client = new WebSocketClient(serverUrl, token);
|
||||
|
||||
// Wait for connection
|
||||
await client.connect();
|
||||
spinner.succeed('Connected!');
|
||||
|
||||
// Wait a moment for the 'joined' event
|
||||
await new Promise((resolve) => {
|
||||
client.once('joined', resolve);
|
||||
setTimeout(resolve, 1000); // Fallback timeout
|
||||
});
|
||||
|
||||
console.log(chalk.green('\nJoined session! Press ESC or Ctrl-C to exit.\n'));
|
||||
|
||||
// Create terminal UI
|
||||
const ui = new TerminalUI(client);
|
||||
|
||||
// Handle errors
|
||||
client.on('error', (error: Error) => {
|
||||
console.error(chalk.red('\nConnection error:'), error.message);
|
||||
});
|
||||
|
||||
client.on('reconnect-failed', () => {
|
||||
console.error(chalk.red('\nFailed to reconnect. Exiting...'));
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to connect');
|
||||
console.error(chalk.red((error as Error).message));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import fetch from 'node-fetch';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
|
||||
export async function listSessions(options: { server?: string }): Promise<void> {
|
||||
const serverUrl = options.server || 'http://localhost:3000';
|
||||
const spinner = ora('Fetching sessions...').start();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serverUrl}/api/sessions`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch sessions: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: any = await response.json();
|
||||
spinner.stop();
|
||||
|
||||
if (data.sessions.length === 0) {
|
||||
console.log(chalk.yellow('No active sessions found.'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`\nActive Sessions (${data.sessions.length}):\n`));
|
||||
|
||||
data.sessions.forEach((session: any) => {
|
||||
console.log(chalk.cyan(` ${session.name}`));
|
||||
console.log(` ID: ${chalk.gray(session.id)}`);
|
||||
console.log(` Clients: ${session.activeClients}`);
|
||||
console.log(` Created: ${new Date(session.createdAt).toLocaleString()}`);
|
||||
console.log('');
|
||||
});
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to fetch sessions');
|
||||
console.error(chalk.red((error as Error).message));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import WebSocket from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
export interface TerminalMessage {
|
||||
type: 'output' | 'input' | 'resize' | 'join' | 'leave' | 'presence' | 'joined' | 'error';
|
||||
data?: any;
|
||||
clientId?: string;
|
||||
timestamp?: number;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class WebSocketClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
|
||||
constructor(private url: string, private token: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `${this.url}?token=${this.token}`;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.emit('connected');
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message: TerminalMessage = JSON.parse(data.toString());
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this.emit('disconnected');
|
||||
this.attemptReconnect();
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(message: TerminalMessage): void {
|
||||
switch (message.type) {
|
||||
case 'output':
|
||||
this.emit('output', message.data);
|
||||
break;
|
||||
case 'joined':
|
||||
this.emit('joined', {
|
||||
sessionId: message.sessionId,
|
||||
sessionName: message.sessionName,
|
||||
clientId: message.clientId,
|
||||
});
|
||||
break;
|
||||
case 'presence':
|
||||
this.emit('presence', message.data);
|
||||
break;
|
||||
case 'error':
|
||||
this.emit('error', new Error(message.message || 'Unknown error'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sendInput(data: string): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'input',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'resize',
|
||||
data: { cols, rows },
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private attemptReconnect(): void {
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts++;
|
||||
setTimeout(() => {
|
||||
this.emit('reconnecting', this.reconnectAttempts);
|
||||
this.connect().catch(() => {
|
||||
// Reconnection failed, will retry
|
||||
});
|
||||
}, 1000 * this.reconnectAttempts);
|
||||
} else {
|
||||
this.emit('reconnect-failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { createSession } from './commands/create';
|
||||
import { joinSession } from './commands/join';
|
||||
import { listSessions } from './commands/list';
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('multmux')
|
||||
.description('Collaborative terminal tool with tmux backend')
|
||||
.version('0.1.0');
|
||||
|
||||
program
|
||||
.command('create <name>')
|
||||
.description('Create a new collaborative session')
|
||||
.option('-s, --server <url>', 'Server URL', 'http://localhost:3000')
|
||||
.option('-r, --repo <path>', 'Repository path to use')
|
||||
.action(createSession);
|
||||
|
||||
program
|
||||
.command('join <token>')
|
||||
.description('Join an existing session with a token')
|
||||
.option('-s, --server <url>', 'WebSocket server URL', 'ws://localhost:3001')
|
||||
.action(joinSession);
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('List active sessions')
|
||||
.option('-s, --server <url>', 'Server URL', 'http://localhost:3000')
|
||||
.action(listSessions);
|
||||
|
||||
program.parse();
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import blessed from 'blessed';
|
||||
import { WebSocketClient } from '../connection/WebSocketClient';
|
||||
|
||||
export class TerminalUI {
|
||||
private screen: blessed.Widgets.Screen;
|
||||
private terminal: blessed.Widgets.BoxElement;
|
||||
private statusBar: blessed.Widgets.BoxElement;
|
||||
private buffer: string = '';
|
||||
|
||||
constructor(private client: WebSocketClient) {
|
||||
// Create screen
|
||||
this.screen = blessed.screen({
|
||||
smartCSR: true,
|
||||
title: 'mulTmux',
|
||||
});
|
||||
|
||||
// Status bar
|
||||
this.statusBar = blessed.box({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: 1,
|
||||
style: {
|
||||
fg: 'white',
|
||||
bg: 'blue',
|
||||
},
|
||||
content: ' mulTmux - Connecting...',
|
||||
});
|
||||
|
||||
// Terminal output
|
||||
this.terminal = blessed.box({
|
||||
top: 1,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%-1',
|
||||
scrollable: true,
|
||||
alwaysScroll: true,
|
||||
scrollbar: {
|
||||
style: {
|
||||
bg: 'blue',
|
||||
},
|
||||
},
|
||||
keys: true,
|
||||
vi: true,
|
||||
mouse: true,
|
||||
content: '',
|
||||
});
|
||||
|
||||
this.screen.append(this.statusBar);
|
||||
this.screen.append(this.terminal);
|
||||
|
||||
// Focus terminal
|
||||
this.terminal.focus();
|
||||
|
||||
// Setup event handlers
|
||||
this.setupEventHandlers();
|
||||
|
||||
// Render
|
||||
this.screen.render();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// Handle terminal output from server
|
||||
this.client.on('output', (data: string) => {
|
||||
this.buffer += data;
|
||||
this.terminal.setContent(this.buffer);
|
||||
this.terminal.setScrollPerc(100);
|
||||
this.screen.render();
|
||||
});
|
||||
|
||||
// Handle connection events
|
||||
this.client.on('connected', () => {
|
||||
this.updateStatus('Connected', 'green');
|
||||
});
|
||||
|
||||
this.client.on('joined', (info: any) => {
|
||||
this.updateStatus(`Session: ${info.sessionName} (${info.clientId.slice(0, 8)})`, 'green');
|
||||
});
|
||||
|
||||
this.client.on('disconnected', () => {
|
||||
this.updateStatus('Disconnected', 'red');
|
||||
});
|
||||
|
||||
this.client.on('reconnecting', (attempt: number) => {
|
||||
this.updateStatus(`Reconnecting (${attempt}/5)...`, 'yellow');
|
||||
});
|
||||
|
||||
this.client.on('presence', (data: any) => {
|
||||
if (data.action === 'join') {
|
||||
this.showNotification(`User joined (${data.totalClients} online)`);
|
||||
} else if (data.action === 'leave') {
|
||||
this.showNotification(`User left (${data.totalClients} online)`);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard input
|
||||
this.screen.on('keypress', (ch: string, key: any) => {
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send input to server
|
||||
if (ch) {
|
||||
this.client.sendInput(ch);
|
||||
} else if (key.name) {
|
||||
// Handle special keys
|
||||
const specialKeys: { [key: string]: string } = {
|
||||
enter: '\r',
|
||||
backspace: '\x7f',
|
||||
tab: '\t',
|
||||
up: '\x1b[A',
|
||||
down: '\x1b[B',
|
||||
right: '\x1b[C',
|
||||
left: '\x1b[D',
|
||||
};
|
||||
|
||||
if (specialKeys[key.name]) {
|
||||
this.client.sendInput(specialKeys[key.name]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resize
|
||||
this.screen.on('resize', () => {
|
||||
const { width, height } = this.terminal;
|
||||
this.client.resize(width as number, (height as number) - 1);
|
||||
});
|
||||
|
||||
// Quit on Ctrl-C
|
||||
this.screen.key(['C-c'], () => {
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
private updateStatus(text: string, color: string = 'blue'): void {
|
||||
this.statusBar.style.bg = color;
|
||||
this.statusBar.setContent(` mulTmux - ${text}`);
|
||||
this.screen.render();
|
||||
}
|
||||
|
||||
private showNotification(text: string): void {
|
||||
// Append notification to buffer
|
||||
this.buffer += `\n[mulTmux] ${text}\n`;
|
||||
this.terminal.setContent(this.buffer);
|
||||
this.screen.render();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.client.disconnect();
|
||||
this.screen.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "@multmux/server",
|
||||
"version": "0.1.0",
|
||||
"description": "mulTmux server - collaborative terminal backend",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"ws": "^8.16.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"nanoid": "^3.3.7",
|
||||
"cors": "^2.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { Router } from 'express';
|
||||
import { SessionManager } from '../managers/SessionManager';
|
||||
import { TokenManager } from '../managers/TokenManager';
|
||||
|
||||
export function createRouter(
|
||||
sessionManager: SessionManager,
|
||||
tokenManager: TokenManager
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// Create a new session
|
||||
router.post('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { name, repoPath } = req.body;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return res.status(400).json({ error: 'Session name is required' });
|
||||
}
|
||||
|
||||
const session = await sessionManager.createSession(name, repoPath);
|
||||
const token = tokenManager.generateToken(session.id, 60, 'write');
|
||||
|
||||
res.json({
|
||||
session: {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
createdAt: session.createdAt,
|
||||
},
|
||||
token,
|
||||
inviteUrl: `multmux join ${token}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
|
||||
// List active sessions
|
||||
router.get('/sessions', (req, res) => {
|
||||
const sessions = sessionManager.listSessions();
|
||||
res.json({
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
createdAt: s.createdAt,
|
||||
activeClients: s.clients.size,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// Get session info
|
||||
router.get('/sessions/:id', (req, res) => {
|
||||
const session = sessionManager.getSession(req.params.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
createdAt: session.createdAt,
|
||||
activeClients: session.clients.size,
|
||||
});
|
||||
});
|
||||
|
||||
// Generate new invite token for existing session
|
||||
router.post('/sessions/:id/tokens', (req, res) => {
|
||||
const session = sessionManager.getSession(req.params.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const { expiresInMinutes = 60, permissions = 'write' } = req.body;
|
||||
const token = tokenManager.generateToken(session.id, expiresInMinutes, permissions);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
inviteUrl: `multmux join ${token}`,
|
||||
expiresInMinutes,
|
||||
permissions,
|
||||
});
|
||||
});
|
||||
|
||||
// Health check
|
||||
router.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
activeSessions: sessionManager.listSessions().length,
|
||||
activeTokens: tokenManager.getActiveTokens(),
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import express from 'express';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import cors from 'cors';
|
||||
import { SessionManager } from './managers/SessionManager';
|
||||
import { TokenManager } from './managers/TokenManager';
|
||||
import { TerminalHandler } from './websocket/TerminalHandler';
|
||||
import { createRouter } from './api/routes';
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const WS_PORT = process.env.WS_PORT || 3001;
|
||||
|
||||
async function main() {
|
||||
// Initialize managers
|
||||
const sessionManager = new SessionManager();
|
||||
const tokenManager = new TokenManager();
|
||||
const terminalHandler = new TerminalHandler(sessionManager, tokenManager);
|
||||
|
||||
// HTTP API Server
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use('/api', createRouter(sessionManager, tokenManager));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`mulTmux HTTP API listening on port ${PORT}`);
|
||||
});
|
||||
|
||||
// WebSocket Server
|
||||
const wss = new WebSocketServer({ port: Number(WS_PORT) });
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
// Extract token from query string
|
||||
const url = new URL(req.url || '', `http://localhost:${WS_PORT}`);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Token required' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
terminalHandler.handleConnection(ws, token);
|
||||
});
|
||||
|
||||
console.log(`mulTmux WebSocket server listening on port ${WS_PORT}`);
|
||||
console.log('');
|
||||
console.log('mulTmux server is ready!');
|
||||
console.log(`API: http://localhost:${PORT}/api`);
|
||||
console.log(`WebSocket: ws://localhost:${WS_PORT}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as pty from 'node-pty';
|
||||
import { Session } from '../types';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export class SessionManager {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private terminals: Map<string, pty.IPty> = new Map();
|
||||
|
||||
async createSession(name: string, repoPath?: string): Promise<Session> {
|
||||
const id = nanoid(16);
|
||||
const tmuxSessionName = `multmux-${id}`;
|
||||
|
||||
const session: Session = {
|
||||
id,
|
||||
name,
|
||||
createdAt: new Date(),
|
||||
tmuxSessionName,
|
||||
clients: new Set(),
|
||||
repoPath,
|
||||
};
|
||||
|
||||
this.sessions.set(id, session);
|
||||
|
||||
// Create tmux session
|
||||
await this.createTmuxSession(tmuxSessionName, repoPath);
|
||||
|
||||
// Attach to tmux session with pty
|
||||
const terminal = pty.spawn('tmux', ['attach-session', '-t', tmuxSessionName], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: repoPath || process.cwd(),
|
||||
env: process.env as { [key: string]: string },
|
||||
});
|
||||
|
||||
this.terminals.set(id, terminal);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async createTmuxSession(name: string, cwd?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = ['new-session', '-d', '-s', name];
|
||||
if (cwd) {
|
||||
args.push('-c', cwd);
|
||||
}
|
||||
|
||||
const proc = spawn('tmux', args);
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Failed to create tmux session: exit code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getSession(id: string): Session | undefined {
|
||||
return this.sessions.get(id);
|
||||
}
|
||||
|
||||
getTerminal(sessionId: string): pty.IPty | undefined {
|
||||
return this.terminals.get(sessionId);
|
||||
}
|
||||
|
||||
addClient(sessionId: string, clientId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.clients.add(clientId);
|
||||
}
|
||||
}
|
||||
|
||||
removeClient(sessionId: string, clientId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.clients.delete(clientId);
|
||||
|
||||
// Clean up session if no clients left
|
||||
if (session.clients.size === 0) {
|
||||
this.destroySession(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async destroySession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const terminal = this.terminals.get(sessionId);
|
||||
if (terminal) {
|
||||
terminal.kill();
|
||||
this.terminals.delete(sessionId);
|
||||
}
|
||||
|
||||
// Kill tmux session
|
||||
spawn('tmux', ['kill-session', '-t', session.tmuxSessionName]);
|
||||
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
|
||||
listSessions(): Session[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
resizeTerminal(sessionId: string, cols: number, rows: number): void {
|
||||
const terminal = this.terminals.get(sessionId);
|
||||
if (terminal) {
|
||||
terminal.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
import { SessionToken } from '../types';
|
||||
|
||||
export class TokenManager {
|
||||
private tokens: Map<string, SessionToken> = new Map();
|
||||
|
||||
generateToken(
|
||||
sessionId: string,
|
||||
expiresInMinutes: number = 60,
|
||||
permissions: 'read' | 'write' = 'write'
|
||||
): string {
|
||||
const token = nanoid(32);
|
||||
const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
|
||||
|
||||
this.tokens.set(token, {
|
||||
token,
|
||||
sessionId,
|
||||
expiresAt,
|
||||
permissions,
|
||||
});
|
||||
|
||||
// Clean up expired token after expiration
|
||||
setTimeout(() => this.tokens.delete(token), expiresInMinutes * 60 * 1000);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
validateToken(token: string): SessionToken | null {
|
||||
const sessionToken = this.tokens.get(token);
|
||||
|
||||
if (!sessionToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sessionToken.expiresAt < new Date()) {
|
||||
this.tokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
revokeToken(token: string): void {
|
||||
this.tokens.delete(token);
|
||||
}
|
||||
|
||||
getActiveTokens(): number {
|
||||
return this.tokens.size;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
export interface Session {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
tmuxSessionName: string;
|
||||
clients: Set<string>;
|
||||
repoPath?: string;
|
||||
}
|
||||
|
||||
export interface SessionToken {
|
||||
token: string;
|
||||
sessionId: string;
|
||||
expiresAt: Date;
|
||||
permissions: 'read' | 'write';
|
||||
}
|
||||
|
||||
export interface ClientConnection {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
username?: string;
|
||||
permissions: 'read' | 'write';
|
||||
}
|
||||
|
||||
export interface TerminalMessage {
|
||||
type: 'output' | 'input' | 'resize' | 'join' | 'leave' | 'presence';
|
||||
data: any;
|
||||
clientId?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { WebSocket } from 'ws';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { SessionManager } from '../managers/SessionManager';
|
||||
import { TokenManager } from '../managers/TokenManager';
|
||||
import { TerminalMessage, ClientConnection } from '../types';
|
||||
|
||||
export class TerminalHandler {
|
||||
private clients: Map<string, { ws: WebSocket; connection: ClientConnection }> = new Map();
|
||||
|
||||
constructor(
|
||||
private sessionManager: SessionManager,
|
||||
private tokenManager: TokenManager
|
||||
) {}
|
||||
|
||||
handleConnection(ws: WebSocket, token: string): void {
|
||||
// Validate token
|
||||
const sessionToken = this.tokenManager.validateToken(token);
|
||||
if (!sessionToken) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid or expired token' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify session exists
|
||||
const session = this.sessionManager.getSession(sessionToken.sessionId);
|
||||
if (!session) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientId = nanoid(16);
|
||||
const connection: ClientConnection = {
|
||||
id: clientId,
|
||||
sessionId: sessionToken.sessionId,
|
||||
permissions: sessionToken.permissions,
|
||||
};
|
||||
|
||||
this.clients.set(clientId, { ws, connection });
|
||||
this.sessionManager.addClient(sessionToken.sessionId, clientId);
|
||||
|
||||
// Attach terminal output to WebSocket
|
||||
const terminal = this.sessionManager.getTerminal(sessionToken.sessionId);
|
||||
if (terminal) {
|
||||
const onData = (data: string) => {
|
||||
const message: TerminalMessage = {
|
||||
type: 'output',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
};
|
||||
|
||||
terminal.onData(onData);
|
||||
|
||||
// Clean up on disconnect
|
||||
ws.on('close', () => {
|
||||
terminal.off('data', onData);
|
||||
this.handleDisconnect(clientId);
|
||||
});
|
||||
}
|
||||
|
||||
// Send join confirmation
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'joined',
|
||||
sessionId: session.id,
|
||||
sessionName: session.name,
|
||||
clientId,
|
||||
})
|
||||
);
|
||||
|
||||
// Broadcast presence
|
||||
this.broadcastToSession(sessionToken.sessionId, {
|
||||
type: 'presence',
|
||||
data: {
|
||||
action: 'join',
|
||||
clientId,
|
||||
totalClients: session.clients.size,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Handle incoming messages
|
||||
ws.on('message', (data) => {
|
||||
this.handleMessage(clientId, data.toString());
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(clientId: string, rawMessage: string): void {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const message: TerminalMessage = JSON.parse(rawMessage);
|
||||
|
||||
switch (message.type) {
|
||||
case 'input':
|
||||
this.handleInput(client.connection, message.data);
|
||||
break;
|
||||
case 'resize':
|
||||
this.handleResize(client.connection, message.data);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleInput(connection: ClientConnection, data: string): void {
|
||||
if (connection.permissions !== 'write') {
|
||||
return; // Read-only clients can't send input
|
||||
}
|
||||
|
||||
const terminal = this.sessionManager.getTerminal(connection.sessionId);
|
||||
if (terminal) {
|
||||
terminal.write(data);
|
||||
}
|
||||
|
||||
// Broadcast input to other clients for cursor tracking
|
||||
this.broadcastToSession(
|
||||
connection.sessionId,
|
||||
{
|
||||
type: 'input',
|
||||
data,
|
||||
clientId: connection.id,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
connection.id // Exclude sender
|
||||
);
|
||||
}
|
||||
|
||||
private handleResize(connection: ClientConnection, data: { cols: number; rows: number }): void {
|
||||
this.sessionManager.resizeTerminal(connection.sessionId, data.cols, data.rows);
|
||||
}
|
||||
|
||||
private handleDisconnect(clientId: string): void {
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) return;
|
||||
|
||||
this.sessionManager.removeClient(client.connection.sessionId, clientId);
|
||||
this.clients.delete(clientId);
|
||||
|
||||
// Broadcast leave
|
||||
const session = this.sessionManager.getSession(client.connection.sessionId);
|
||||
if (session) {
|
||||
this.broadcastToSession(client.connection.sessionId, {
|
||||
type: 'presence',
|
||||
data: {
|
||||
action: 'leave',
|
||||
clientId,
|
||||
totalClients: session.clients.size,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastToSession(
|
||||
sessionId: string,
|
||||
message: TerminalMessage,
|
||||
excludeClientId?: string
|
||||
): void {
|
||||
const session = this.sessionManager.getSession(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const messageStr = JSON.stringify(message);
|
||||
|
||||
for (const [clientId, client] of this.clients.entries()) {
|
||||
if (client.connection.sessionId === sessionId && clientId !== excludeClientId) {
|
||||
client.ws.send(messageStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
10
package.json
10
package.json
|
|
@ -3,6 +3,9 @@
|
|||
"version": "1.0.0",
|
||||
"description": "Jeff Emmett's personal website",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"multmux/packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others --names client,worker --prefix-colors blue,red \"npm run dev:client\" \"npm run dev:worker:local\"",
|
||||
"dev:client": "vite --host 0.0.0.0 --port 5173",
|
||||
|
|
@ -15,7 +18,12 @@
|
|||
"deploy:pages": "tsc && vite build",
|
||||
"deploy:worker": "wrangler deploy",
|
||||
"deploy:worker:dev": "wrangler deploy --config wrangler.dev.toml",
|
||||
"types": "tsc --noEmit"
|
||||
"types": "tsc --noEmit",
|
||||
"multmux:install": "npm install --workspaces",
|
||||
"multmux:build": "npm run build --workspace=@multmux/server --workspace=@multmux/cli",
|
||||
"multmux:dev:server": "npm run dev --workspace=@multmux/server",
|
||||
"multmux:dev:cli": "npm run dev --workspace=@multmux/cli",
|
||||
"multmux:start": "npm run start --workspace=@multmux/server"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Jeff Emmett",
|
||||
|
|
|
|||
Loading…
Reference in New Issue