Henry commited on
Commit
eaef622
·
1 Parent(s): c09b231
Dockerfile ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Node 20 slim base suitable for VPS
2
+ FROM node:20-slim
3
+
4
+ ENV DEBIAN_FRONTEND=noninteractive
5
+
6
+ # System deps for node-pty (node-gyp toolchain) and useful terminal tools
7
+ RUN apt-get update && \
8
+ apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 install -y --no-install-recommends \
9
+ bash git python3 python3-pip python3-venv python-is-python3 build-essential \
10
+ tmux ncurses-term rclone rsync nano ssh curl && \
11
+ rm -rf /var/lib/apt/lists/* && \
12
+ pip install --no-cache-dir --break-system-packages PyInstaller
13
+
14
+ # Python QoL defaults
15
+ ENV PYTHONUNBUFFERED=1 \
16
+ PYTHONDONTWRITEBYTECODE=1 \
17
+ PIP_DISABLE_PIP_VERSION_CHECK=1
18
+
19
+ # Convenience symlinks for common typos
20
+ RUN ln -sf /usr/bin/python3 /usr/local/bin/pyhon && \
21
+ ln -sf /usr/bin/python3 /usr/local/bin/ptyhon
22
+
23
+ # install Claude Code CLI
24
+ RUN npm install -g @anthropic-ai/claude-code
25
+
26
+ WORKDIR /app
27
+
28
+ # Preinstall Python packages used in terminal
29
+ COPY requirements.txt ./
30
+ RUN pip install --no-cache-dir --break-system-packages --timeout=60 -r requirements.txt || true
31
+
32
+ # Node deps
33
+ COPY package.json ./
34
+ RUN npm install --production
35
+
36
+ # App source
37
+ COPY server.js ./
38
+ COPY scripts ./scripts
39
+ COPY public ./public
40
+
41
+ # Make backup scripts available on PATH
42
+ COPY scripts/download_claude_backup.sh /usr/local/bin/download_claude_backup.sh
43
+ COPY scripts/upload_claude_backup.sh /usr/local/bin/upload_claude_backup.sh
44
+ COPY scripts/terminal-anywhere-setup.sh /usr/local/bin/terminal-anywhere-setup.sh
45
+ COPY scripts/setup.sh /usr/local/bin/setup.sh
46
+ COPY scripts/ssh-setup.sh /usr/local/bin/ssh-setup
47
+ RUN chmod +x /usr/local/bin/ssh-setup
48
+ RUN chmod +x /usr/local/bin/download_claude_backup.sh
49
+ RUN chmod +x /usr/local/bin/upload_claude_backup.sh
50
+ RUN chmod +x /usr/local/bin/terminal-anywhere-setup.sh
51
+ RUN chmod +x /usr/local/bin/setup.sh
52
+
53
+ ENV PORT=7860 \
54
+ NODE_ENV=production
55
+ EXPOSE 7860
56
+
57
+ CMD ["sh", "-c", "/usr/local/bin/setup.sh"]
58
+
README.md CHANGED
@@ -1,10 +1,55 @@
1
- ---
2
- title: Test Terminal
3
- emoji: 👀
4
- colorFrom: red
5
- colorTo: indigo
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ **Overview**
2
+ - Purpose: Secure, browser-based terminal you can deploy on a VPS using Docker. Uses `xterm.js` in the browser with a `node-pty` backend.
3
+ - Security: Requires a shared secret token (`TERMINAL_TOKEN`) to open a terminal session.
4
+
5
+ **Quick Start**
6
+ - Build and run with Docker Compose:
7
+ - `cp .env.example .env` and fill in `TERMINAL_TOKEN` (and optional vars).
8
+ - `docker compose up -d --build`
9
+ - Open `http://<your-vps-ip>:<HOST_PORT>` and click Connect (enter token).
10
+
11
+ **Environment**
12
+ - `TERMINAL_TOKEN`: Shared secret required by the UI to connect.
13
+ - `HOST_PORT`: Host port to expose (default 7860).
14
+ - `JOB_WEBHOOK_TOKEN`: Optional. If set, secures `POST /tasks/heartbeat`.
15
+ - `SLACK_BOT_TOKEN`, `SLACK_CHANNEL_ID`: Optional. Send heartbeats to Slack.
16
+ - `PCLOUD_USERNAME`, `PCLOUD_PASSWORD`: Optional. Backup `~/.claude` to pCloud.
17
+ - `HEARTBEAT_LOG_PATH`: Optional. Where heartbeat requests are logged.
18
+ - `INSTALL_CLAUDE_CLI`: Optional `true|false`. Install Claude CLI during build.
19
+
20
+ **Ports**
21
+ - App listens on container port `7860`. Host port is controlled by `HOST_PORT` in `.env`.
22
+
23
+ **Persistent Data**
24
+ - Named volume `claude_data` is mounted at `/root/.claude` to persist Claude CLI tokens/config across restarts.
25
+
26
+ **Docker**
27
+ - Build image: `docker build -t claude-vps-web-terminal .`
28
+ - Run container: `docker run -d --name claude-vps-web-terminal -p 7860:7860 -e TERMINAL_TOKEN=yourtoken -v claude_data:/root/.claude claude-vps-web-terminal`
29
+
30
+ **Reverse Proxy (optional)**
31
+ - Put behind nginx/traefik/caddy and forward WebSocket `Upgrade` headers.
32
+ - Example nginx snippet:
33
+ - `location / { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; }`
34
+
35
+ **API Endpoints**
36
+ - `GET /health`: Returns `ok`.
37
+ - `POST /tasks/heartbeat`: Sends a message to Slack. If `JOB_WEBHOOK_TOKEN` is set, include header `Authorization: Bearer <token>` or `?token=` query.
38
+
39
+ **Security Notes**
40
+ - Always set a strong `TERMINAL_TOKEN`. Anyone with the token can open a shell in the container.
41
+ - Use a firewall and/or reverse proxy to restrict access by IP if needed.
42
+ - For TLS, terminate at your reverse proxy (nginx/traefik/caddy/Cloudflare) and forward to the container.
43
+
44
+ **Files**
45
+ - `server.js`: Express server and WebSocket bridge to `node-pty`.
46
+ - `public/*`: Minimal terminal UI using `xterm.js`.
47
+ - `scripts/upload_claude_to_pcloud.py`: Optional pCloud backup of `~/.claude` to `/claude-vps/.claude` and `~/.claude.json` to `/claude-vps`.
48
+ - `scripts/download_claude_from_pcloud.py`: Restore backup from `/claude-vps/.claude` to `~/.claude` and `/claude-vps/.claude.json` to `~/.claude.json`. Supports `--overwrite` to replace existing local files.
49
+ - `Dockerfile`: Node 20 base with Python + build tools for `node-pty` and terminal QoL tools (tmux, rclone, rsync).
50
+ - `docker-compose.yml`: One-service stack with env vars and volume for persistence.
51
+
52
+ **Local Dev**
53
+ - `npm install`
54
+ - `npm start` then visit `http://localhost:7860`.
55
+ - Set `TERMINAL_TOKEN=yourtoken npm start` to require a token locally.
docker-compose.yml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+ services:
3
+ web-terminal:
4
+ build:
5
+ context: .
6
+ dockerfile: Dockerfile
7
+ args:
8
+ # set to "true" to include the Claude CLI inside the image
9
+ INSTALL_CLAUDE_CLI: ${INSTALL_CLAUDE_CLI-false}
10
+ image: claude-vps-web-terminal:latest
11
+ container_name: claude-vps-web-terminal
12
+ restart: unless-stopped
13
+ ports:
14
+ - "${HOST_PORT-7860}:7860"
15
+ environment:
16
+ # required to gate access to the terminal UI
17
+ - TERMINAL_TOKEN=${TERMINAL_TOKEN}
18
+ # optional: provide SSH keys via env (used by entrypoint to create /root/.ssh)
19
+ - SSH_PUBLIC_KEY=${SSH_PUBLIC_KEY}
20
+ - SSH_PRIVATE_KEY=${SSH_PRIVATE_KEY}
21
+ - SSH_OPTS=${SSH_OPTS}
22
+ # optional: rsync remote for backup restore
23
+ - REMOTE_HOST=${REMOTE_HOST}
24
+ - REMOTE_PATH=${REMOTE_PATH}
25
+ volumes:
26
+ # persist Claude CLI auth/config across restarts
27
+ - claude_data:/root/.claude
28
+ # optional: persist tmp logs if desired
29
+ # - ./data/tmp:/tmp
30
+
31
+ volumes:
32
+ claude_data: {}
package.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "vps-web-terminal",
3
+ "version": "1.0.0",
4
+ "description": "Secure browser terminal for VPS using xterm.js + node-pty",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js"
8
+ },
9
+ "dependencies": {
10
+ "express": "^4.19.2",
11
+ "node-pty": "^1.0.0",
12
+ "ws": "^8.18.0"
13
+ }
14
+ }
15
+
public/index.html ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>VPS Web Terminal</title>
7
+ <link rel="stylesheet" href="https://unpkg.com/xterm/css/xterm.css" />
8
+ <link rel="stylesheet" href="/style.css" />
9
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' https://unpkg.com 'unsafe-inline'; script-src 'self' https://unpkg.com 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:;">
10
+ <meta http-equiv="Referrer-Policy" content="no-referrer" />
11
+ </head>
12
+ <body>
13
+ <div class="bar">
14
+ <div class="left">
15
+ <strong>Web Terminal</strong>
16
+ </div>
17
+ <div class="right">
18
+ <label>Token: <input id="token" type="password" placeholder="required if set" /></label>
19
+ <button id="connect">Connect</button>
20
+ <button id="clear">Clear</button>
21
+ </div>
22
+ </div>
23
+ <div id="terminal"></div>
24
+
25
+ <script src="https://unpkg.com/xterm/lib/xterm.js"></script>
26
+ <script src="https://unpkg.com/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
27
+ <script>
28
+ const term = new window.Terminal({
29
+ fontFamily: 'Menlo, Monaco, Consolas, monospace',
30
+ fontSize: 14,
31
+ convertEol: true,
32
+ cursorBlink: true,
33
+ scrollback: 5000,
34
+ theme: { background: '#111111' }
35
+ });
36
+ const fitAddon = new window.FitAddon.FitAddon();
37
+ term.loadAddon(fitAddon);
38
+
39
+ const el = document.getElementById('terminal');
40
+ term.open(el);
41
+ fitAddon.fit();
42
+
43
+ let ws;
44
+
45
+ function connect() {
46
+ if (ws && ws.readyState === WebSocket.OPEN) return;
47
+ const token = document.getElementById('token').value.trim();
48
+ const proto = (location.protocol === 'https:') ? 'wss' : 'ws';
49
+ const url = `${proto}://${location.host}/ws${token ? `?token=${encodeURIComponent(token)}` : ''}`;
50
+ ws = new WebSocket(url);
51
+
52
+ ws.onopen = () => {
53
+ term.focus();
54
+ sendResize();
55
+ };
56
+
57
+ ws.onmessage = (ev) => {
58
+ try {
59
+ const msg = JSON.parse(ev.data);
60
+ if (msg.type === 'output' && typeof msg.data === 'string') {
61
+ term.write(msg.data);
62
+ }
63
+ } catch (_) {}
64
+ };
65
+
66
+ ws.onclose = (ev) => {
67
+ term.write(`\r\n\x1b[31m[disconnected: ${ev.code}]\x1b[0m\r\n`);
68
+ };
69
+
70
+ ws.onerror = () => {
71
+ term.write('\r\n\x1b[31m[ws error]\x1b[0m\r\n');
72
+ };
73
+ }
74
+
75
+ function sendResize() {
76
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
77
+ const cols = term.cols;
78
+ const rows = term.rows;
79
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }));
80
+ }
81
+
82
+ // forward user keystrokes to pty
83
+ term.onData(data => {
84
+ if (ws && ws.readyState === WebSocket.OPEN) {
85
+ ws.send(JSON.stringify({ type: 'input', data }));
86
+ }
87
+ });
88
+
89
+ window.addEventListener('resize', () => {
90
+ fitAddon.fit();
91
+ sendResize();
92
+ });
93
+
94
+ document.getElementById('connect').addEventListener('click', connect);
95
+ document.getElementById('clear').addEventListener('click', () => term.clear());
96
+
97
+ // initial hint
98
+ term.write('\x1b[32m[ready]\x1b[0m type to begin — click Connect first.\r\n');
99
+ </script>
100
+ </body>
101
+ </html>
public/style.css ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ html, body {
2
+ height: 100%;
3
+ margin: 0;
4
+ }
5
+
6
+ body {
7
+ background: #111;
8
+ color: #eee;
9
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
10
+ }
11
+
12
+ .bar {
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: space-between;
16
+ padding: 8px 12px;
17
+ background: #1b1b1b;
18
+ border-bottom: 1px solid #2a2a2a;
19
+ }
20
+
21
+ .right label {
22
+ margin-right: 8px;
23
+ }
24
+
25
+ #terminal {
26
+ position: absolute;
27
+ left: 0;
28
+ right: 0;
29
+ top: 44px;
30
+ bottom: 0;
31
+ }
32
+
33
+ input[type="password"] {
34
+ background: #222;
35
+ color: #eee;
36
+ border: 1px solid #333;
37
+ padding: 4px 6px;
38
+ }
39
+
40
+ button {
41
+ background: #333;
42
+ color: #eee;
43
+ border: 1px solid #444;
44
+ padding: 4px 8px;
45
+ margin-left: 6px;
46
+ cursor: pointer;
47
+ }
48
+
49
+ button:hover {
50
+ background: #3a3a3a;
51
+ }
52
+
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ mcp
2
+ pcloud
3
+ ptyprocess
4
+ websockets
5
+ gradio
6
+ plotly
7
+ rich
8
+ textual
9
+ asyncio
10
+ psutil
run.sh ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ docker compose up -d --build
2
+ docker compose logs -f
scripts/download_claude_backup.sh ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Claude Configuration Restore Script
4
+ # Downloads ~/.claude.json and ~/.claude/ folder from rsync.net storage
5
+ # Usage: ./download_claude_backup.sh [OPTIONS]
6
+
7
+ set -euo pipefail
8
+
9
+ # Allow environment overrides while keeping sane defaults
10
+ REMOTE_HOST="${REMOTE_HOST:-zh5486@zh5486.rsync.net}"
11
+ REMOTE_PATH="${REMOTE_PATH:-vps/claude_code/host_1}"
12
+ # SSH options: accept new host keys automatically to avoid interactive prompt
13
+ # You can override via SSH_OPTS env (e.g., to pin known_hosts instead)
14
+ SSH_OPTS="${SSH_OPTS:--o StrictHostKeyChecking=accept-new}"
15
+ LOG_DIR="$HOME/.claude_backup_logs"
16
+ LOG_FILE="$LOG_DIR/download_$(date +%Y%m%d_%H%M%S).log"
17
+
18
+ # Default values
19
+ DEFAULT_DEST="$HOME"
20
+ DEST_DIR="$DEFAULT_DEST"
21
+ MODE="override"
22
+ DRY_RUN=false
23
+
24
+ # Colors for output
25
+ RED='\033[0;31m'
26
+ GREEN='\033[0;32m'
27
+ YELLOW='\033[1;33m'
28
+ BLUE='\033[0;34m'
29
+ NC='\033[0m' # No Color
30
+
31
+ # Function to log messages
32
+ log() {
33
+ local level=$1
34
+ shift
35
+ local message="$*"
36
+ local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
37
+
38
+ # Create log directory if it doesn't exist
39
+ mkdir -p "$LOG_DIR"
40
+
41
+ # Write to log file
42
+ echo "[$timestamp] [$level] $message" >> "$LOG_FILE"
43
+
44
+ # Print to console with colors
45
+ case $level in
46
+ "ERROR") echo -e "${RED}[ERROR]${NC} $message" ;;
47
+ "SUCCESS") echo -e "${GREEN}[SUCCESS]${NC} $message" ;;
48
+ "WARNING") echo -e "${YELLOW}[WARNING]${NC} $message" ;;
49
+ "INFO") echo -e "${BLUE}[INFO]${NC} $message" ;;
50
+ *) echo "[$level] $message" ;;
51
+ esac
52
+ }
53
+
54
+ # Function to show usage
55
+ show_help() {
56
+ cat << EOF
57
+ Claude Configuration Restore Script
58
+
59
+ Usage: $0 [OPTIONS]
60
+
61
+ OPTIONS:
62
+ --override Overwrite existing files (default)
63
+ --skip Skip existing files, only download new/missing files
64
+ --dest PATH Destination directory (default: $DEFAULT_DEST)
65
+ --dry-run Show what would be downloaded without actually doing it
66
+ --help Show this help message
67
+
68
+ MODES:
69
+ --override: Downloads all files and overwrites existing ones
70
+ Creates backup of existing files before overwriting
71
+
72
+ --skip: Only downloads files that don't exist locally
73
+ Preserves all existing local files
74
+
75
+ EXAMPLES:
76
+ $0 # Download to ~/ with override mode
77
+ $0 --skip # Download missing files only
78
+ $0 --dest /tmp/claude_restore # Download to custom location
79
+ $0 --skip --dest /backup/claude # Download missing files to custom location
80
+ $0 --dry-run # Preview what would be downloaded
81
+
82
+ This script restores:
83
+ - .claude.json
84
+ - .claude/ folder
85
+
86
+ From: $REMOTE_HOST:$REMOTE_PATH/
87
+
88
+ Requirements:
89
+ - SSH key authentication must be set up for $REMOTE_HOST
90
+ - rsync must be installed
91
+
92
+ Logs are saved to: $LOG_DIR/
93
+ EOF
94
+ }
95
+
96
+ # Parse command line arguments
97
+ while [[ $# -gt 0 ]]; do
98
+ case $1 in
99
+ --override)
100
+ MODE="override"
101
+ shift
102
+ ;;
103
+ --skip)
104
+ MODE="skip"
105
+ shift
106
+ ;;
107
+ --dest)
108
+ if [[ -z "${2:-}" ]]; then
109
+ log "ERROR" "--dest requires a path argument"
110
+ exit 1
111
+ fi
112
+ DEST_DIR="$2"
113
+ shift 2
114
+ ;;
115
+ --dry-run)
116
+ DRY_RUN=true
117
+ shift
118
+ ;;
119
+ --help|-h)
120
+ show_help
121
+ exit 0
122
+ ;;
123
+ *)
124
+ log "ERROR" "Unknown option: $1"
125
+ show_help
126
+ exit 1
127
+ ;;
128
+ esac
129
+ done
130
+
131
+ # Validate and create destination directory
132
+ setup_destination() {
133
+ # Expand tilde if present
134
+ DEST_DIR="${DEST_DIR/#\~/$HOME}"
135
+
136
+ log "INFO" "Destination directory: $DEST_DIR"
137
+ log "INFO" "Mode: $MODE"
138
+
139
+ if $DRY_RUN; then
140
+ log "INFO" "[DRY RUN] Would use destination: $DEST_DIR"
141
+ return
142
+ fi
143
+
144
+ if [[ ! -d "$DEST_DIR" ]]; then
145
+ log "INFO" "Creating destination directory: $DEST_DIR"
146
+ if ! mkdir -p "$DEST_DIR"; then
147
+ log "ERROR" "Failed to create destination directory: $DEST_DIR"
148
+ exit 1
149
+ fi
150
+ fi
151
+
152
+ if [[ ! -w "$DEST_DIR" ]]; then
153
+ log "ERROR" "Destination directory is not writable: $DEST_DIR"
154
+ exit 1
155
+ fi
156
+
157
+ log "SUCCESS" "Destination directory ready: $DEST_DIR"
158
+ }
159
+
160
+ # Test SSH connection and check remote files
161
+ test_remote_connection() {
162
+ log "INFO" "Testing SSH connection to $REMOTE_HOST..."
163
+
164
+ if ! ssh $SSH_OPTS -o ConnectTimeout=10 -o BatchMode=yes "$REMOTE_HOST" "echo 'SSH connection successful'" >/dev/null 2>&1; then
165
+ log "ERROR" "Failed to connect to $REMOTE_HOST"
166
+ log "ERROR" "Please ensure:"
167
+ log "ERROR" " 1. SSH key authentication is set up"
168
+ log "ERROR" " 2. The host is reachable"
169
+ log "ERROR" " 3. Your SSH key is loaded (ssh-add -l)"
170
+ exit 1
171
+ fi
172
+
173
+ log "SUCCESS" "SSH connection successful"
174
+
175
+ # Check if remote backup directory exists
176
+ log "INFO" "Checking remote backup directory..."
177
+ if ! ssh $SSH_OPTS "$REMOTE_HOST" "test -d '$REMOTE_PATH'"; then
178
+ log "ERROR" "Remote backup directory does not exist: $REMOTE_PATH"
179
+ log "ERROR" "Please run the upload script first to create a backup"
180
+ exit 1
181
+ fi
182
+
183
+ log "SUCCESS" "Remote backup directory found: $REMOTE_PATH"
184
+ }
185
+
186
+ # Create backup of existing files before override
187
+ backup_existing_files() {
188
+ if [[ "$MODE" != "override" ]]; then
189
+ return
190
+ fi
191
+
192
+ local backup_dir="$DEST_DIR/.claude_backup_$(date +%Y%m%d_%H%M%S)"
193
+ local files_to_backup=()
194
+
195
+ # Check what exists locally
196
+ if [[ -f "$DEST_DIR/.claude.json" ]]; then
197
+ files_to_backup+=(".claude.json")
198
+ fi
199
+
200
+ if [[ -d "$DEST_DIR/.claude" ]]; then
201
+ files_to_backup+=(".claude/")
202
+ fi
203
+
204
+ if [[ ${#files_to_backup[@]} -eq 0 ]]; then
205
+ log "INFO" "No existing files to backup"
206
+ return
207
+ fi
208
+
209
+ if $DRY_RUN; then
210
+ log "INFO" "[DRY RUN] Would backup existing files to: $backup_dir"
211
+ for file in "${files_to_backup[@]}"; do
212
+ log "INFO" "[DRY RUN] Would backup: $DEST_DIR/$file"
213
+ done
214
+ return
215
+ fi
216
+
217
+ log "INFO" "Creating backup of existing files..."
218
+ mkdir -p "$backup_dir"
219
+
220
+ for file in "${files_to_backup[@]}"; do
221
+ log "INFO" "Backing up existing: $file"
222
+ if cp -r "$DEST_DIR/$file" "$backup_dir/"; then
223
+ log "SUCCESS" "Backed up: $file to $backup_dir/"
224
+ else
225
+ log "WARNING" "Failed to backup: $file"
226
+ fi
227
+ done
228
+ }
229
+
230
+ # Download files using rsync
231
+ download_files() {
232
+ local rsync_opts="-avz --progress"
233
+
234
+ case $MODE in
235
+ "override")
236
+ # Default rsync behavior - overwrites existing files
237
+ ;;
238
+ "skip")
239
+ # Skip existing files
240
+ rsync_opts="$rsync_opts --ignore-existing"
241
+ ;;
242
+ esac
243
+
244
+ if $DRY_RUN; then
245
+ rsync_opts="$rsync_opts --dry-run"
246
+ log "INFO" "[DRY RUN] Showing what would be downloaded..."
247
+ else
248
+ log "INFO" "Starting download from $REMOTE_HOST:$REMOTE_PATH"
249
+ log "INFO" "Using mode: $MODE"
250
+ fi
251
+
252
+ # Download .claude.json if it exists remotely
253
+ log "INFO" "Checking for .claude.json on remote server..."
254
+ if ssh $SSH_OPTS "$REMOTE_HOST" "test -f '$REMOTE_PATH/.claude.json'"; then
255
+ log "INFO" "Downloading .claude.json..."
256
+ if rsync -e "ssh $SSH_OPTS" $rsync_opts "$REMOTE_HOST:$REMOTE_PATH/.claude.json" "$DEST_DIR/"; then
257
+ if ! $DRY_RUN; then
258
+ log "SUCCESS" ".claude.json downloaded successfully"
259
+ fi
260
+ else
261
+ log "ERROR" "Failed to download .claude.json"
262
+ exit 1
263
+ fi
264
+ else
265
+ log "WARNING" ".claude.json not found on remote server"
266
+ fi
267
+
268
+ # Download .claude folder if it exists remotely
269
+ log "INFO" "Checking for .claude/ folder on remote server..."
270
+ if ssh $SSH_OPTS "$REMOTE_HOST" "test -d '$REMOTE_PATH/claude'"; then
271
+ log "INFO" "Downloading .claude/ folder..."
272
+
273
+ # Create local .claude directory if it doesn't exist
274
+ if ! $DRY_RUN && [[ ! -d "$DEST_DIR/.claude" ]]; then
275
+ mkdir -p "$DEST_DIR/.claude"
276
+ fi
277
+
278
+ if rsync -e "ssh $SSH_OPTS" $rsync_opts "$REMOTE_HOST:$REMOTE_PATH/claude/" "$DEST_DIR/.claude/"; then
279
+ if ! $DRY_RUN; then
280
+ log "SUCCESS" ".claude/ folder downloaded successfully"
281
+ fi
282
+ else
283
+ log "ERROR" "Failed to download .claude/ folder"
284
+ exit 1
285
+ fi
286
+ else
287
+ log "WARNING" ".claude/ folder not found on remote server"
288
+ fi
289
+ }
290
+
291
+ # Show summary of what was downloaded/would be downloaded
292
+ show_summary() {
293
+ log "INFO" "Download summary:"
294
+ log "INFO" " Source: $REMOTE_HOST:$REMOTE_PATH"
295
+ log "INFO" " Destination: $DEST_DIR"
296
+ log "INFO" " Mode: $MODE"
297
+
298
+ if $DRY_RUN; then
299
+ log "INFO" " Status: DRY RUN - no files were actually transferred"
300
+ else
301
+ log "INFO" " Status: Completed successfully"
302
+
303
+ # Show what was actually downloaded
304
+ if [[ -f "$DEST_DIR/.claude.json" ]]; then
305
+ log "INFO" " Downloaded: .claude.json"
306
+ fi
307
+
308
+ if [[ -d "$DEST_DIR/.claude" ]]; then
309
+ log "INFO" " Downloaded: .claude/ folder"
310
+ fi
311
+ fi
312
+ }
313
+
314
+ # Main execution
315
+ main() {
316
+ log "INFO" "Starting Claude configuration restore..."
317
+ log "INFO" "Log file: $LOG_FILE"
318
+
319
+ if $DRY_RUN; then
320
+ log "INFO" "Running in DRY RUN mode - no files will be transferred"
321
+ fi
322
+
323
+ setup_destination
324
+ test_remote_connection
325
+ backup_existing_files
326
+ download_files
327
+ show_summary
328
+
329
+ if $DRY_RUN; then
330
+ log "INFO" "[DRY RUN] Restore simulation completed"
331
+ else
332
+ log "SUCCESS" "Restore completed successfully!"
333
+ fi
334
+ }
335
+
336
+ # Check if rsync is available
337
+ if ! command -v rsync &> /dev/null; then
338
+ log "ERROR" "rsync is not installed. Please install rsync first."
339
+ exit 1
340
+ fi
341
+
342
+ # Run main function
343
+ main "$@"
scripts/setup.sh ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ echo "test"
2
+ /usr/local/bin/ssh-setup 2>&1
3
+ /usr/local/bin/terminal-anywhere-setup.sh 2>&1
4
+ node server
scripts/ssh-setup.sh ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+
5
+ if [ -f /.env ]; then
6
+ export $(grep -v '^#' /.env | xargs)
7
+ fi
8
+ # Write SSH keys from env vars into /root/.ssh with correct permissions.
9
+ # Handles both real newlines and literal \n sequences in SSH_PRIVATE_KEY.
10
+
11
+ HOME_DIR="${HOME:-/root}"
12
+ SSH_DIR="$HOME_DIR/.ssh"
13
+
14
+ mkdir -p "$SSH_DIR"
15
+ chmod 700 "$SSH_DIR"
16
+
17
+ echo "created folder $SSH_DIR"
18
+
19
+ # Public key (optional)
20
+ if [ -n "${SSH_PUBLIC_KEY:-}" ]; then
21
+ printf "%s\n" "$SSH_PUBLIC_KEY" > "$SSH_DIR/id_rsa.pub"
22
+ echo "Created id_rsa.pub"
23
+ chmod 644 "$SSH_DIR/id_rsa.pub"
24
+ fi
25
+
26
+ # Private key (optional)
27
+ if [ -n "${SSH_PRIVATE_KEY:-}" ]; then
28
+ # If the key contains literal \n sequences, convert them to newlines.
29
+ if printf "%s" "$SSH_PRIVATE_KEY" | grep -q '\\n'; then
30
+ printf "%s" "$SSH_PRIVATE_KEY" | sed 's/\\n/\n/g' > "$SSH_DIR/id_rsa"
31
+ echo "created id_rsa"
32
+ else
33
+ printf "%s" "$SSH_PRIVATE_KEY" > "$SSH_DIR/id_rsa"
34
+ fi
35
+ chmod 600 "$SSH_DIR/id_rsa"
36
+ fi
37
+
38
+ # Prime known_hosts to avoid host key prompts for common git hosts
39
+ if command -v ssh-keyscan >/dev/null 2>&1; then
40
+ for host in github.com gitlab.com; do
41
+ ssh-keyscan -H "$host" 2>/dev/null >> "$SSH_DIR/known_hosts" || true
42
+ done
43
+ # Optionally pre-seed remote backup host if provided via REMOTE_HOST (user@host)
44
+ if [ -n "${REMOTE_HOST:-}" ]; then
45
+ remote_host_domain="$(printf "%s" "$REMOTE_HOST" | awk -F'@' '{print $NF}')"
46
+ if [ -n "$remote_host_domain" ]; then
47
+ ssh-keyscan -H "$remote_host_domain" 2>/dev/null >> "$SSH_DIR/known_hosts" || true
48
+ fi
49
+ fi
50
+ chmod 644 "$SSH_DIR/known_hosts" || true
51
+ fi
52
+
53
+
54
+ #rm /app/scripts/*.sh
55
+ #rm ~/.ssh/id_rsa
56
+ #rm ~/.ssh/id_rsa.pub
57
+
58
+ exec "$@"
scripts/terminal-anywhere-setup.sh ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ echo "setup terminal-anywhere"
2
+ echo "clone repo"
3
+ cd ~/
4
+ rm -f -r terminal-anywhere-source
5
+ /usr/bin/git clone git@github.com:scantrancher/terminal-anywhere-source.git
6
+ cd ~/terminal-anywhere-source/
7
+ /usr/bin/bash run.sh
scripts/upload_claude_backup.sh ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Claude Configuration Backup Script
4
+ # Backs up ~/.claude.json and ~/.claude/ folder to rsync.net storage
5
+ # Usage: ./upload_claude_backup.sh [--dry-run]
6
+
7
+ set -euo pipefail
8
+
9
+ REMOTE_HOST="zh5486@zh5486.rsync.net"
10
+ REMOTE_PATH="vps/claude_code/host_1"
11
+ LOG_DIR="$HOME/.claude_backup_logs"
12
+ LOG_FILE="$LOG_DIR/upload_$(date +%Y%m%d_%H%M%S).log"
13
+
14
+ # Colors for output
15
+ RED='\033[0;31m'
16
+ GREEN='\033[0;32m'
17
+ YELLOW='\033[1;33m'
18
+ BLUE='\033[0;34m'
19
+ NC='\033[0m' # No Color
20
+
21
+ # Function to log messages
22
+ log() {
23
+ local level=$1
24
+ shift
25
+ local message="$*"
26
+ local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
27
+
28
+ # Create log directory if it doesn't exist
29
+ mkdir -p "$LOG_DIR"
30
+
31
+ # Write to log file
32
+ echo "[$timestamp] [$level] $message" >> "$LOG_FILE"
33
+
34
+ # Print to console with colors
35
+ case $level in
36
+ "ERROR") echo -e "${RED}[ERROR]${NC} $message" ;;
37
+ "SUCCESS") echo -e "${GREEN}[SUCCESS]${NC} $message" ;;
38
+ "WARNING") echo -e "${YELLOW}[WARNING]${NC} $message" ;;
39
+ "INFO") echo -e "${BLUE}[INFO]${NC} $message" ;;
40
+ *) echo "[$level] $message" ;;
41
+ esac
42
+ }
43
+
44
+ # Function to show usage
45
+ show_help() {
46
+ cat << EOF
47
+ Claude Configuration Backup Script
48
+
49
+ Usage: $0 [OPTIONS]
50
+
51
+ OPTIONS:
52
+ --dry-run Show what would be backed up without actually doing it
53
+ --help Show this help message
54
+
55
+ This script backs up:
56
+ - ~/.claude.json
57
+ - ~/.claude/ folder
58
+
59
+ To: $REMOTE_HOST:$REMOTE_PATH/
60
+
61
+ Requirements:
62
+ - SSH key authentication must be set up for $REMOTE_HOST
63
+ - rsync must be installed
64
+
65
+ Logs are saved to: $LOG_DIR/
66
+ EOF
67
+ }
68
+
69
+ # Parse command line arguments
70
+ DRY_RUN=false
71
+ while [[ $# -gt 0 ]]; do
72
+ case $1 in
73
+ --dry-run)
74
+ DRY_RUN=true
75
+ shift
76
+ ;;
77
+ --help|-h)
78
+ show_help
79
+ exit 0
80
+ ;;
81
+ *)
82
+ log "ERROR" "Unknown option: $1"
83
+ show_help
84
+ exit 1
85
+ ;;
86
+ esac
87
+ done
88
+
89
+ # Check if required files/folders exist
90
+ check_source_files() {
91
+ local missing_files=()
92
+
93
+ if [[ ! -f "$HOME/.claude.json" ]]; then
94
+ missing_files+=("~/.claude.json")
95
+ fi
96
+
97
+ if [[ ! -d "$HOME/.claude" ]]; then
98
+ missing_files+=("~/.claude/")
99
+ fi
100
+
101
+ if [[ ${#missing_files[@]} -gt 0 ]]; then
102
+ log "WARNING" "Some Claude configuration files are missing:"
103
+ for file in "${missing_files[@]}"; do
104
+ log "WARNING" " - $file"
105
+ done
106
+ log "WARNING" "Backup will proceed with available files only."
107
+ fi
108
+ }
109
+
110
+ # Test SSH connection
111
+ test_ssh_connection() {
112
+ log "INFO" "Testing SSH connection to $REMOTE_HOST..."
113
+
114
+ if ! ssh -o ConnectTimeout=10 -o BatchMode=yes "$REMOTE_HOST" "echo 'SSH connection successful'" >/dev/null 2>&1; then
115
+ log "ERROR" "Failed to connect to $REMOTE_HOST"
116
+ log "ERROR" "Please ensure:"
117
+ log "ERROR" " 1. SSH key authentication is set up"
118
+ log "ERROR" " 2. The host is reachable"
119
+ log "ERROR" " 3. Your SSH key is loaded (ssh-add -l)"
120
+ exit 1
121
+ fi
122
+
123
+ log "SUCCESS" "SSH connection successful"
124
+ }
125
+
126
+ # Create remote directory if it doesn't exist
127
+ create_remote_directory() {
128
+ log "INFO" "Creating remote directory structure..."
129
+
130
+ if $DRY_RUN; then
131
+ log "INFO" "[DRY RUN] Would create directory: $REMOTE_HOST:$REMOTE_PATH"
132
+ return
133
+ fi
134
+
135
+ if ssh "$REMOTE_HOST" "mkdir -p '$REMOTE_PATH'"; then
136
+ log "SUCCESS" "Remote directory ready: $REMOTE_PATH"
137
+ else
138
+ log "ERROR" "Failed to create remote directory: $REMOTE_PATH"
139
+ exit 1
140
+ fi
141
+ }
142
+
143
+ # Backup files using rsync
144
+ backup_files() {
145
+ local rsync_opts="-avz --progress --delete"
146
+
147
+ if $DRY_RUN; then
148
+ rsync_opts="$rsync_opts --dry-run"
149
+ log "INFO" "[DRY RUN] Showing what would be backed up..."
150
+ else
151
+ log "INFO" "Starting backup to $REMOTE_HOST:$REMOTE_PATH"
152
+ fi
153
+
154
+ # Backup .claude.json if it exists
155
+ if [[ -f "$HOME/.claude.json" ]]; then
156
+ log "INFO" "Backing up ~/.claude.json..."
157
+ if rsync $rsync_opts "$HOME/.claude.json" "$REMOTE_HOST:$REMOTE_PATH/"; then
158
+ if ! $DRY_RUN; then
159
+ log "SUCCESS" "~/.claude.json backed up successfully"
160
+ fi
161
+ else
162
+ log "ERROR" "Failed to backup ~/.claude.json"
163
+ exit 1
164
+ fi
165
+ fi
166
+
167
+ # Backup .claude folder if it exists
168
+ if [[ -d "$HOME/.claude" ]]; then
169
+ log "INFO" "Backing up ~/.claude/ folder..."
170
+ if rsync $rsync_opts "$HOME/.claude/" "$REMOTE_HOST:$REMOTE_PATH/claude/"; then
171
+ if ! $DRY_RUN; then
172
+ log "SUCCESS" "~/.claude/ folder backed up successfully"
173
+ fi
174
+ else
175
+ log "ERROR" "Failed to backup ~/.claude/ folder"
176
+ exit 1
177
+ fi
178
+ fi
179
+ }
180
+
181
+ # Main execution
182
+ main() {
183
+ log "INFO" "Starting Claude configuration backup..."
184
+ log "INFO" "Log file: $LOG_FILE"
185
+
186
+ if $DRY_RUN; then
187
+ log "INFO" "Running in DRY RUN mode - no files will be transferred"
188
+ fi
189
+
190
+ check_source_files
191
+ test_ssh_connection
192
+ create_remote_directory
193
+ backup_files
194
+
195
+ if $DRY_RUN; then
196
+ log "INFO" "[DRY RUN] Backup simulation completed"
197
+ else
198
+ log "SUCCESS" "Backup completed successfully!"
199
+ log "INFO" "Remote location: $REMOTE_HOST:$REMOTE_PATH"
200
+ fi
201
+ }
202
+
203
+ # Check if rsync is available
204
+ if ! command -v rsync &> /dev/null; then
205
+ log "ERROR" "rsync is not installed. Please install rsync first."
206
+ exit 1
207
+ fi
208
+
209
+ # Run main function
server.js ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const http = require('http');
2
+ const express = require('express');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const { WebSocketServer } = require('ws');
6
+ const { spawn } = require('child_process');
7
+ const pty = require('node-pty');
8
+
9
+ const app = express();
10
+ const server = http.createServer(app);
11
+
12
+ const PORT = process.env.PORT || 7860;
13
+ const TERMINAL_TOKEN = process.env.TERMINAL_TOKEN || ""; // set in env for security
14
+ const JOB_WEBHOOK_TOKEN = process.env.JOB_WEBHOOK_TOKEN || ""; // bearer token for webhook auth
15
+ const SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN || ""; // xoxb- token from Slack app
16
+ const SLACK_CHANNEL_ID = process.env.SLACK_CHANNEL_ID || ""; // channel ID to post heartbeat
17
+ const HEARTBEAT_LOG_PATH = process.env.HEARTBEAT_LOG_PATH || '/tmp/heartbeat.log';
18
+
19
+ // If behind a reverse proxy (nginx/traefik), trust X-Forwarded-* for req.ip
20
+ app.set('trust proxy', true);
21
+
22
+ // Serve static client
23
+ app.use(express.static(path.join(__dirname, 'public')));
24
+ // JSON parser for webhook
25
+ app.use(express.json());
26
+
27
+ // Basic liveness endpoint
28
+ app.get('/health', (_req, res) => res.status(200).send('ok'));
29
+
30
+ // Minimal webhook to send a heartbeat message to Slack
31
+ app.post('/tasks/heartbeat', async (req, res) => {
32
+ try {
33
+ const authHeader = req.headers['authorization'] || '';
34
+ const bearer = authHeader.startsWith('Bearer ')
35
+ ? authHeader.slice('Bearer '.length)
36
+ : (req.query.token || '');
37
+ // Enforce only when JOB_WEBHOOK_TOKEN is set
38
+ if (JOB_WEBHOOK_TOKEN && bearer !== JOB_WEBHOOK_TOKEN) {
39
+ return res.sendStatus(401);
40
+ }
41
+ if (!SLACK_BOT_TOKEN || !SLACK_CHANNEL_ID) {
42
+ return res.status(500).json({ error: 'Slack not configured' });
43
+ }
44
+
45
+ const now = new Date().toISOString();
46
+ const host = req.headers.host || 'unknown-host';
47
+ const text = `Heartbeat: VPS web terminal alive at ${now} (host: ${host})`;
48
+
49
+ const resp = await fetch('https://slack.com/api/chat.postMessage', {
50
+ method: 'POST',
51
+ headers: {
52
+ 'Authorization': `Bearer ${SLACK_BOT_TOKEN}`,
53
+ 'Content-Type': 'application/json'
54
+ },
55
+ body: JSON.stringify({ channel: SLACK_CHANNEL_ID, text })
56
+ });
57
+ const data = await resp.json().catch(() => ({}));
58
+ const ip = req.ip;
59
+ const ua = req.headers['user-agent'] || '';
60
+ const logLine = `${now}\tip=${ip}\tua=${JSON.stringify(ua)}\tstatus=${data && data.ok ? 'ok' : 'error'}\n`;
61
+ try {
62
+ const dir = path.dirname(HEARTBEAT_LOG_PATH);
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ fs.appendFileSync(HEARTBEAT_LOG_PATH, logLine, 'utf8');
65
+ } catch (_) { /* ignore log failures */ }
66
+
67
+ // Kick off pCloud backup: ~/.claude -> /claude-vps/.claude and ~/.claude.json -> /claude-vps (non-blocking)
68
+ try {
69
+ const child = spawn('python', ['scripts/upload_claude_to_pcloud.py'], {
70
+ cwd: __dirname,
71
+ stdio: 'inherit',
72
+ env: process.env,
73
+ detached: false
74
+ });
75
+ // no await; run in background
76
+ } catch (_) { /* ignore spawn errors */ }
77
+
78
+ if (!data.ok) {
79
+ return res.status(502).json({ error: 'Slack API error', details: data });
80
+ }
81
+ return res.status(202).json({ ok: true, ts: data.ts, channel: data.channel });
82
+ } catch (err) {
83
+ return res.status(500).json({ error: 'server_error' });
84
+ }
85
+ });
86
+
87
+ // WebSocket for terminal
88
+ const wss = new WebSocketServer({ server, path: '/ws' });
89
+
90
+ wss.on('connection', (ws, req) => {
91
+ try {
92
+ // Simple shared-secret check via query string: /ws?token=...
93
+ const url = new URL(req.url, `http://${req.headers.host}`);
94
+ const token = url.searchParams.get('token') || '';
95
+ if (TERMINAL_TOKEN && token !== TERMINAL_TOKEN) {
96
+ ws.close(1008, 'Invalid token'); // Policy Violation
97
+ return;
98
+ }
99
+
100
+ const shell = process.env.SHELL || '/bin/bash';
101
+
102
+ const ptyProcess = pty.spawn(shell, ['-l'], {
103
+ name: 'xterm-256color',
104
+ cols: 80,
105
+ rows: 24,
106
+ cwd: process.env.HOME || process.cwd(),
107
+ env: process.env
108
+ });
109
+
110
+ ptyProcess.onData(data => {
111
+ if (ws.readyState === ws.OPEN) {
112
+ ws.send(JSON.stringify({ type: 'output', data }));
113
+ }
114
+ });
115
+
116
+ ws.on('message', (msg) => {
117
+ try {
118
+ const { type, data, cols, rows } = JSON.parse(msg.toString());
119
+ if (type === 'input' && typeof data === 'string') {
120
+ ptyProcess.write(data);
121
+ } else if (type === 'resize' && Number.isInteger(cols) && Number.isInteger(rows)) {
122
+ ptyProcess.resize(cols, rows);
123
+ }
124
+ } catch (_) {/* ignore malformed */}
125
+ });
126
+
127
+ ws.on('close', () => {
128
+ try { ptyProcess.kill(); } catch (_) {}
129
+ });
130
+ } catch (err) {
131
+ try { ws.close(1011, 'Server error'); } catch (_) {}
132
+ }
133
+ });
134
+
135
+ server.listen(PORT, '0.0.0.0', () => {
136
+ console.log(`Web terminal listening on :${PORT}`);
137
+ });