@remotego/remotego 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/bin/remotego.js +135 -0
- package/commands/remoting-stop.md +14 -0
- package/commands/remoting.md +19 -0
- package/package.json +38 -0
- package/scripts/remoting-stop.sh +8 -0
- package/scripts/remoting.sh +30 -0
- package/server/package.json +15 -0
- package/server/public/index.html +203 -0
- package/server/server.js +256 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# remotego
|
|
2
|
+
|
|
3
|
+
Expose any CLI tool as a public web terminal via tunnel.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @zhanju/remotego
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
remotego <command> [command-args...] [options]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Options
|
|
18
|
+
|
|
19
|
+
| Option | Description | Default |
|
|
20
|
+
|--------|-------------|---------|
|
|
21
|
+
| `--port <port>` | Port to listen on | `7681` |
|
|
22
|
+
| `--cwd <dir>` | Working directory for the command | Current directory |
|
|
23
|
+
| `--help, -h` | Show help | |
|
|
24
|
+
|
|
25
|
+
### Examples
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Mirror Claude Code
|
|
29
|
+
remotego claude
|
|
30
|
+
|
|
31
|
+
# Mirror a bash shell
|
|
32
|
+
remotego bash
|
|
33
|
+
|
|
34
|
+
# Mirror Python REPL
|
|
35
|
+
remotego python3 -i
|
|
36
|
+
|
|
37
|
+
# Mirror vim editor
|
|
38
|
+
remotego vim
|
|
39
|
+
|
|
40
|
+
# Custom port
|
|
41
|
+
remotego --port 9000 node
|
|
42
|
+
|
|
43
|
+
# Pass flags to the command (use -- to separate)
|
|
44
|
+
remotego -- git log --oneline
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How It Works
|
|
48
|
+
|
|
49
|
+
1. Spawns the given command in a PTY (pseudo-terminal)
|
|
50
|
+
2. Starts a local HTTP server with a browser-based terminal (xterm.js)
|
|
51
|
+
3. Creates a public tunnel via localhost.run for remote access
|
|
52
|
+
4. Opens the browser automatically
|
|
53
|
+
|
|
54
|
+
### Security
|
|
55
|
+
|
|
56
|
+
- A random session UUID is generated on each start
|
|
57
|
+
- The session ID is embedded in the URL — only holders of the URL can connect
|
|
58
|
+
- Clients must authenticate within 5 seconds of connecting
|
|
59
|
+
|
|
60
|
+
## Claude Code Plugin
|
|
61
|
+
|
|
62
|
+
This project also works as a Claude Code plugin. Install it and use the `/remoting` slash command to mirror your Claude Code session in a browser.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/bin/remotego.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// remotego - Expose any CLI tool as a public web terminal
|
|
4
|
+
// Usage: remotego <command> [args...] [--port <port>] [--cwd <dir>]
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { resolve, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
|
|
13
|
+
// Parse arguments: everything before flags is the command + its args
|
|
14
|
+
function parseArgs(argv) {
|
|
15
|
+
const cmd = [];
|
|
16
|
+
const flags = {};
|
|
17
|
+
let i = 0;
|
|
18
|
+
|
|
19
|
+
// Collect command and args until we hit a --flag
|
|
20
|
+
while (i < argv.length && !argv[i].startsWith('--')) {
|
|
21
|
+
cmd.push(argv[i]);
|
|
22
|
+
i++;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Parse flags
|
|
26
|
+
while (i < argv.length) {
|
|
27
|
+
const arg = argv[i];
|
|
28
|
+
if (arg === '--port' && argv[i + 1]) {
|
|
29
|
+
flags.port = argv[++i];
|
|
30
|
+
} else if (arg === '--cwd' && argv[i + 1]) {
|
|
31
|
+
flags.cwd = argv[++i];
|
|
32
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
33
|
+
printHelp();
|
|
34
|
+
process.exit(0);
|
|
35
|
+
} else if (arg === '--') {
|
|
36
|
+
// Everything after -- is command args
|
|
37
|
+
i++;
|
|
38
|
+
while (i < argv.length) {
|
|
39
|
+
cmd.push(argv[i]);
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
console.error(`Unknown option: ${arg}`);
|
|
44
|
+
printHelp();
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { cmd, flags };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printHelp() {
|
|
54
|
+
console.log(`
|
|
55
|
+
remotego - Expose any CLI tool as a public web terminal
|
|
56
|
+
|
|
57
|
+
Usage:
|
|
58
|
+
remotego <command> [command-args...] [options]
|
|
59
|
+
remotego --port 9000 --cwd ~/project vim
|
|
60
|
+
remotego bash
|
|
61
|
+
remotego python3 -i
|
|
62
|
+
remotego claude
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
--port <port> Port to listen on (default: 7681)
|
|
66
|
+
--cwd <dir> Working directory for the command (default: current dir)
|
|
67
|
+
--help, -h Show this help message
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
remotego claude # Mirror Claude Code
|
|
71
|
+
remotego vim # Mirror vim editor
|
|
72
|
+
remotego bash # Mirror a bash shell
|
|
73
|
+
remotego python3 -i # Mirror Python REPL
|
|
74
|
+
remotego --port 9000 node # Mirror Node.js REPL on port 9000
|
|
75
|
+
remotego -- --flagged-arg # Use -- to separate flags from command args
|
|
76
|
+
`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { cmd, flags } = parseArgs(process.argv.slice(2));
|
|
80
|
+
|
|
81
|
+
if (cmd.length === 0) {
|
|
82
|
+
console.error('Error: No command specified.\n');
|
|
83
|
+
printHelp();
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const serverDir = resolve(__dirname, '..', 'server');
|
|
88
|
+
|
|
89
|
+
// Build environment for server.js
|
|
90
|
+
const env = {
|
|
91
|
+
...process.env,
|
|
92
|
+
REMOTE_CMD: cmd[0],
|
|
93
|
+
REMOTE_ARGS: JSON.stringify(cmd.slice(1)),
|
|
94
|
+
REMOTE_CWD: flags.cwd ? resolve(flags.cwd) : process.cwd(),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (flags.port) {
|
|
98
|
+
env.PORT = flags.port;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Install server dependencies if needed
|
|
102
|
+
import { existsSync } from 'fs';
|
|
103
|
+
if (!existsSync(resolve(serverDir, 'node_modules'))) {
|
|
104
|
+
console.log('Installing dependencies...');
|
|
105
|
+
const install = spawn('npm', ['install'], { cwd: serverDir, stdio: 'inherit' });
|
|
106
|
+
install.on('exit', (code) => {
|
|
107
|
+
if (code !== 0) {
|
|
108
|
+
console.error('Failed to install dependencies');
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
startServer();
|
|
112
|
+
});
|
|
113
|
+
} else {
|
|
114
|
+
startServer();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function startServer() {
|
|
118
|
+
const server = spawn('node', [resolve(serverDir, 'server.js')], {
|
|
119
|
+
cwd: serverDir,
|
|
120
|
+
env,
|
|
121
|
+
stdio: 'inherit',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
server.on('exit', (code) => {
|
|
125
|
+
process.exit(code || 0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
process.on('SIGINT', () => {
|
|
129
|
+
server.kill('SIGINT');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
process.on('SIGTERM', () => {
|
|
133
|
+
server.kill('SIGTERM');
|
|
134
|
+
});
|
|
135
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Stop the running remoting browser mirror session"
|
|
3
|
+
allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/remoting-stop.sh:*)"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /remoting-stop
|
|
7
|
+
|
|
8
|
+
Execute the stop script:
|
|
9
|
+
|
|
10
|
+
```!
|
|
11
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/remoting-stop.sh"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Then tell the user: The remoting session runs in the foreground. Press Ctrl+C to stop it, or type /exit in Claude Code.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Mirror your Claude Code terminal in a browser for remote viewing and interaction"
|
|
3
|
+
argument-hint: "[port]"
|
|
4
|
+
allowed-tools: ["Bash(${CLAUDE_PLUGIN_ROOT}/scripts/remoting.sh:*)"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /remoting - Browser Terminal Mirror
|
|
8
|
+
|
|
9
|
+
Execute the remoting script to launch a browser mirror of this terminal:
|
|
10
|
+
|
|
11
|
+
```!
|
|
12
|
+
"${CLAUDE_PLUGIN_ROOT}/scripts/remoting.sh" $ARGUMENTS
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
If the output contains `MISSING:`, tell the user which dependency is missing and how to install it, then stop.
|
|
16
|
+
If the output contains `ERROR:`, show the error to the user and suggest troubleshooting steps.
|
|
17
|
+
Otherwise, the script will print local and public URLs — share these with anyone who needs to view the terminal.
|
|
18
|
+
|
|
19
|
+
To stop the session: press Ctrl+C in the terminal, or type /exit in Claude Code.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@remotego/remotego",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Expose any CLI tool as a public web terminal via tunnel",
|
|
5
|
+
"bin": {
|
|
6
|
+
"remotego": "bin/remotego.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/",
|
|
10
|
+
"server/",
|
|
11
|
+
"scripts/",
|
|
12
|
+
"commands/",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"terminal",
|
|
17
|
+
"remote",
|
|
18
|
+
"tunnel",
|
|
19
|
+
"pty",
|
|
20
|
+
"web-terminal",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"author": "zhanju",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/topcheer/claude-remoting#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/topcheer/claude-remoting/issues"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/topcheer/claude-remoting.git",
|
|
36
|
+
"directory": "."
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# remoting-stop.sh - The remoting session runs in foreground
|
|
4
|
+
# To stop: press Ctrl+C or exit Claude Code
|
|
5
|
+
|
|
6
|
+
echo "The remoting session runs in the foreground."
|
|
7
|
+
echo "To stop: press Ctrl+C in the terminal, or type /exit in Claude Code."
|
|
8
|
+
echo "STOPPED"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# remoting.sh - Mirror a CLI tool in a browser (Claude plugin default: claude)
|
|
4
|
+
# Usage: remoting.sh [port]
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
PORT="${1:-7681}"
|
|
9
|
+
|
|
10
|
+
# Check Node.js
|
|
11
|
+
if ! command -v node &>/dev/null; then
|
|
12
|
+
echo "MISSING:node:Visit https://nodejs.org/ to install"
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
17
|
+
SERVER_DIR="$(dirname "$SCRIPT_DIR")/server"
|
|
18
|
+
|
|
19
|
+
# Install dependencies if needed
|
|
20
|
+
if [ ! -d "$SERVER_DIR/node_modules" ]; then
|
|
21
|
+
echo "Installing dependencies..."
|
|
22
|
+
(cd "$SERVER_DIR" && npm install)
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# Start server in foreground -- replaces this shell process
|
|
26
|
+
# Default command is 'claude' for the Claude plugin
|
|
27
|
+
# Save caller's cwd so the command starts in the right directory
|
|
28
|
+
REMOTE_CWD="$(pwd)"
|
|
29
|
+
cd "$SERVER_DIR"
|
|
30
|
+
exec env PORT="$PORT" REMOTE_CMD="claude" REMOTE_ARGS="[]" REMOTE_CWD="$REMOTE_CWD" node server.js
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "remoting-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "WebSocket PTY server for remotego",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"node-pty": "^1.2.0-beta.12",
|
|
12
|
+
"ws": "^8.18.0",
|
|
13
|
+
"express": "^4.21.2"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Remotego</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
|
|
8
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
|
|
9
|
+
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
|
10
|
+
<style>
|
|
11
|
+
* {
|
|
12
|
+
margin: 0;
|
|
13
|
+
padding: 0;
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
}
|
|
16
|
+
body {
|
|
17
|
+
background: #0d1117;
|
|
18
|
+
height: 100vh;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
}
|
|
22
|
+
#terminal {
|
|
23
|
+
flex: 1;
|
|
24
|
+
padding: 4px;
|
|
25
|
+
}
|
|
26
|
+
#error-container {
|
|
27
|
+
color: #da3633;
|
|
28
|
+
padding: 20px;
|
|
29
|
+
font-family: system-ui, sans-serif;
|
|
30
|
+
font-size: 16px;
|
|
31
|
+
display: none;
|
|
32
|
+
}
|
|
33
|
+
.status {
|
|
34
|
+
position: fixed;
|
|
35
|
+
top: 10px;
|
|
36
|
+
right: 10px;
|
|
37
|
+
padding: 6px 12px;
|
|
38
|
+
border-radius: 4px;
|
|
39
|
+
font-size: 12px;
|
|
40
|
+
font-family: system-ui, sans-serif;
|
|
41
|
+
z-index: 100;
|
|
42
|
+
}
|
|
43
|
+
.status.connected {
|
|
44
|
+
background: #238636;
|
|
45
|
+
color: white;
|
|
46
|
+
}
|
|
47
|
+
.status.disconnected {
|
|
48
|
+
background: #da3633;
|
|
49
|
+
color: white;
|
|
50
|
+
}
|
|
51
|
+
.status.connecting {
|
|
52
|
+
background: #d29922;
|
|
53
|
+
color: white;
|
|
54
|
+
}
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<div id="status" class="status connecting">Connecting...</div>
|
|
59
|
+
<div id="error-container"></div>
|
|
60
|
+
<div id="terminal"></div>
|
|
61
|
+
|
|
62
|
+
<script>
|
|
63
|
+
const statusEl = document.getElementById('status');
|
|
64
|
+
const terminalEl = document.getElementById('terminal');
|
|
65
|
+
const errorEl = document.getElementById('error-container');
|
|
66
|
+
|
|
67
|
+
// Extract session ID from URL
|
|
68
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
69
|
+
const sessionId = urlParams.get('session');
|
|
70
|
+
|
|
71
|
+
if (!sessionId) {
|
|
72
|
+
errorEl.textContent = 'Error: No session ID in URL. Use the URL shown in the terminal.';
|
|
73
|
+
errorEl.style.display = 'block';
|
|
74
|
+
statusEl.textContent = 'No Session';
|
|
75
|
+
statusEl.className = 'status disconnected';
|
|
76
|
+
throw new Error('No session ID');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Initialize xterm.js
|
|
80
|
+
const terminal = new Terminal({
|
|
81
|
+
cursorBlink: true,
|
|
82
|
+
fontSize: 14,
|
|
83
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
84
|
+
theme: {
|
|
85
|
+
background: '#0d1117',
|
|
86
|
+
foreground: '#c9d1d9',
|
|
87
|
+
cursor: '#c9d1d9',
|
|
88
|
+
black: '#484f58',
|
|
89
|
+
red: '#ff7b72',
|
|
90
|
+
green: '#3fb950',
|
|
91
|
+
yellow: '#d29922',
|
|
92
|
+
blue: '#58a6ff',
|
|
93
|
+
magenta: '#bc8cff',
|
|
94
|
+
cyan: '#39c5cf',
|
|
95
|
+
white: '#b1bac4',
|
|
96
|
+
brightBlack: '#6e7681',
|
|
97
|
+
brightRed: '#ffa198',
|
|
98
|
+
brightGreen: '#56d364',
|
|
99
|
+
brightYellow: '#e3b341',
|
|
100
|
+
brightBlue: '#79c0ff',
|
|
101
|
+
brightMagenta: '#d2a8ff',
|
|
102
|
+
brightCyan: '#56d4dd',
|
|
103
|
+
brightWhite: '#f0f6fc',
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
terminal.open(terminalEl);
|
|
108
|
+
|
|
109
|
+
// Fit addon for auto-sizing
|
|
110
|
+
let fitAddon = null;
|
|
111
|
+
try {
|
|
112
|
+
fitAddon = new FitAddon.FitAddon();
|
|
113
|
+
terminal.loadAddon(fitAddon);
|
|
114
|
+
fitAddon.fit();
|
|
115
|
+
} catch (_) {}
|
|
116
|
+
|
|
117
|
+
// WebSocket connection
|
|
118
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
119
|
+
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
|
120
|
+
let activeWs = null;
|
|
121
|
+
let dead = false;
|
|
122
|
+
|
|
123
|
+
function connect() {
|
|
124
|
+
if (dead) return;
|
|
125
|
+
|
|
126
|
+
const ws = new WebSocket(wsUrl);
|
|
127
|
+
|
|
128
|
+
ws.onopen = () => {
|
|
129
|
+
ws.send(JSON.stringify({ type: 'auth', sessionId: sessionId }));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
ws.onmessage = (event) => {
|
|
133
|
+
const msg = JSON.parse(event.data);
|
|
134
|
+
|
|
135
|
+
if (msg.type === 'auth_ok') {
|
|
136
|
+
activeWs = ws;
|
|
137
|
+
statusEl.textContent = 'Connected';
|
|
138
|
+
statusEl.className = 'status connected';
|
|
139
|
+
terminal.focus();
|
|
140
|
+
if (fitAddon) fitAddon.fit();
|
|
141
|
+
// Send initial terminal size to server PTY
|
|
142
|
+
if (terminal.cols && terminal.rows) {
|
|
143
|
+
ws.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows }));
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (msg.type === 'data') {
|
|
149
|
+
terminal.write(msg.data);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (msg.type === 'history') {
|
|
154
|
+
terminal.write(msg.data);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (msg.type === 'exit') {
|
|
159
|
+
terminal.write('\r\n\n[Session ended]');
|
|
160
|
+
statusEl.textContent = 'Session ended';
|
|
161
|
+
statusEl.className = 'status disconnected';
|
|
162
|
+
dead = true;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
ws.onclose = (event) => {
|
|
168
|
+
if (ws === activeWs) activeWs = null;
|
|
169
|
+
if (event.code === 4003 || event.code === 4001) {
|
|
170
|
+
statusEl.textContent = 'Session expired';
|
|
171
|
+
statusEl.className = 'status disconnected';
|
|
172
|
+
dead = true;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
statusEl.textContent = 'Reconnecting...';
|
|
176
|
+
statusEl.className = 'status connecting';
|
|
177
|
+
setTimeout(connect, 3000);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
ws.onerror = () => {
|
|
181
|
+
// onclose handles reconnection
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Register input handler once, outside connect()
|
|
186
|
+
terminal.onData((data) => {
|
|
187
|
+
if (activeWs && activeWs.readyState === WebSocket.OPEN) {
|
|
188
|
+
activeWs.send(JSON.stringify({ type: 'input', data }));
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
connect();
|
|
193
|
+
|
|
194
|
+
// Handle window resize → notify PTY
|
|
195
|
+
window.addEventListener('resize', () => {
|
|
196
|
+
if (fitAddon) fitAddon.fit();
|
|
197
|
+
if (activeWs && activeWs.readyState === WebSocket.OPEN && terminal.cols && terminal.rows) {
|
|
198
|
+
activeWs.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows }));
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
</script>
|
|
202
|
+
</body>
|
|
203
|
+
</html>
|
package/server/server.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { spawn } from 'node-pty';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
6
|
+
import { createServer } from 'http';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
import { execFile } from 'child_process';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
|
|
13
|
+
const PORT = process.env.PORT || 7681;
|
|
14
|
+
const SESSION_ID = randomUUID();
|
|
15
|
+
const clients = new Set();
|
|
16
|
+
|
|
17
|
+
// Command to run in PTY — configurable via env vars
|
|
18
|
+
const REMOTE_CMD = process.env.REMOTE_CMD || 'claude';
|
|
19
|
+
const REMOTE_ARGS = process.env.REMOTE_ARGS
|
|
20
|
+
? JSON.parse(process.env.REMOTE_ARGS)
|
|
21
|
+
: [];
|
|
22
|
+
|
|
23
|
+
// Buffer all PTY output so new clients can catch up
|
|
24
|
+
const outputBuffer = [];
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// HTTP + WebSocket Server
|
|
28
|
+
// ============================================================
|
|
29
|
+
const app = express();
|
|
30
|
+
app.use(express.static(join(__dirname, 'public')));
|
|
31
|
+
const server = createServer(app);
|
|
32
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
33
|
+
|
|
34
|
+
// ============================================================
|
|
35
|
+
// WebSocket: Auth + Input forwarding
|
|
36
|
+
// ============================================================
|
|
37
|
+
wss.on('connection', (ws) => {
|
|
38
|
+
let authenticated = false;
|
|
39
|
+
const authTimeout = setTimeout(() => {
|
|
40
|
+
if (!authenticated) {
|
|
41
|
+
ws.close(4001, 'Auth timeout');
|
|
42
|
+
}
|
|
43
|
+
}, 5000);
|
|
44
|
+
|
|
45
|
+
ws.on('message', (message) => {
|
|
46
|
+
try {
|
|
47
|
+
const msg = JSON.parse(message);
|
|
48
|
+
|
|
49
|
+
// First message must be auth
|
|
50
|
+
if (!authenticated) {
|
|
51
|
+
if (msg.type === 'auth' && msg.sessionId === SESSION_ID) {
|
|
52
|
+
authenticated = true;
|
|
53
|
+
clearTimeout(authTimeout);
|
|
54
|
+
clients.add(ws);
|
|
55
|
+
ws.send(JSON.stringify({ type: 'auth_ok' }));
|
|
56
|
+
// Send all cached output to the new client
|
|
57
|
+
if (outputBuffer.length > 0) {
|
|
58
|
+
const history = JSON.stringify({ type: 'history', data: outputBuffer.join('') });
|
|
59
|
+
ws.send(history);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
ws.close(4003, 'Invalid session');
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// After auth: forward input to PTY
|
|
68
|
+
if (msg.type === 'input') {
|
|
69
|
+
ptyProcess.write(msg.data);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handle resize from browser → update PTY size
|
|
73
|
+
if (msg.type === 'resize') {
|
|
74
|
+
ptyCols = msg.cols || 120;
|
|
75
|
+
ptyRows = msg.rows || 36;
|
|
76
|
+
try { ptyProcess.resize(ptyCols, ptyRows); } catch (_) {}
|
|
77
|
+
}
|
|
78
|
+
} catch (_) {
|
|
79
|
+
// Ignore malformed messages
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
ws.on('close', () => {
|
|
84
|
+
clients.delete(ws);
|
|
85
|
+
clearTimeout(authTimeout);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
ws.on('error', () => {
|
|
89
|
+
clients.delete(ws);
|
|
90
|
+
clearTimeout(authTimeout);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ============================================================
|
|
95
|
+
// Singleton PTY: wraps the configured command
|
|
96
|
+
// ============================================================
|
|
97
|
+
// Track the largest viewport so PTY matches browser
|
|
98
|
+
let ptyCols = 120;
|
|
99
|
+
let ptyRows = 36;
|
|
100
|
+
|
|
101
|
+
const ptyProcess = spawn(REMOTE_CMD, REMOTE_ARGS, {
|
|
102
|
+
name: 'xterm-256color',
|
|
103
|
+
cols: ptyCols,
|
|
104
|
+
rows: ptyRows,
|
|
105
|
+
cwd: process.env.REMOTE_CWD || process.cwd(),
|
|
106
|
+
env: { ...process.env },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// PTY output → local terminal + all WebSocket clients
|
|
110
|
+
ptyProcess.onData((data) => {
|
|
111
|
+
// Cache output for new clients
|
|
112
|
+
outputBuffer.push(data);
|
|
113
|
+
|
|
114
|
+
// Local terminal
|
|
115
|
+
process.stdout.write(data);
|
|
116
|
+
|
|
117
|
+
// Broadcast to all authenticated browser clients
|
|
118
|
+
const msg = JSON.stringify({ type: 'data', data });
|
|
119
|
+
for (const client of clients) {
|
|
120
|
+
if (client.readyState === 1) { // WebSocket.OPEN
|
|
121
|
+
client.send(msg);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// PTY exit → cleanup and exit
|
|
127
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
128
|
+
for (const client of clients) {
|
|
129
|
+
if (client.readyState === 1) {
|
|
130
|
+
client.send(JSON.stringify({ type: 'exit', code: exitCode }));
|
|
131
|
+
client.close();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
cleanup();
|
|
135
|
+
process.exit(exitCode || 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// ============================================================
|
|
139
|
+
// Local terminal: stdin raw mode → PTY
|
|
140
|
+
// ============================================================
|
|
141
|
+
if (process.stdin.isTTY) {
|
|
142
|
+
process.stdin.setRawMode(true);
|
|
143
|
+
process.stdin.resume();
|
|
144
|
+
process.stdin.setEncoding('utf8');
|
|
145
|
+
process.stdin.on('data', (data) => {
|
|
146
|
+
ptyProcess.write(data);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Note: PTY resize is controlled by browser viewport, not local terminal
|
|
151
|
+
|
|
152
|
+
// ============================================================
|
|
153
|
+
// Orphan detection: if parent dies, we should exit too
|
|
154
|
+
// ============================================================
|
|
155
|
+
setInterval(() => {
|
|
156
|
+
try {
|
|
157
|
+
process.kill(process.ppid, 0);
|
|
158
|
+
} catch {
|
|
159
|
+
// Parent process is dead — we're an orphan
|
|
160
|
+
ptyProcess.kill();
|
|
161
|
+
cleanup();
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
}, 2000);
|
|
165
|
+
|
|
166
|
+
// ============================================================
|
|
167
|
+
// Signal handlers
|
|
168
|
+
// ============================================================
|
|
169
|
+
process.on('SIGINT', () => {
|
|
170
|
+
ptyProcess.kill('SIGINT');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
process.on('SIGTERM', () => {
|
|
174
|
+
ptyProcess.kill();
|
|
175
|
+
cleanup();
|
|
176
|
+
process.exit(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// ============================================================
|
|
180
|
+
// Cleanup
|
|
181
|
+
// ============================================================
|
|
182
|
+
function cleanup() {
|
|
183
|
+
try { process.stdin.setRawMode(false); } catch (_) {}
|
|
184
|
+
process.stdin.pause();
|
|
185
|
+
for (const client of clients) {
|
|
186
|
+
client.close();
|
|
187
|
+
}
|
|
188
|
+
server.close();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ============================================================
|
|
192
|
+
// Start server + auto-open browser + public tunnel
|
|
193
|
+
// ============================================================
|
|
194
|
+
server.listen(PORT, () => {
|
|
195
|
+
const localUrl = `http://localhost:${PORT}?session=${SESSION_ID}`;
|
|
196
|
+
|
|
197
|
+
// Show local URL in terminal
|
|
198
|
+
process.stderr.write(`\n[remoting] Command: ${REMOTE_CMD} ${REMOTE_ARGS.join(' ')}\n`);
|
|
199
|
+
process.stderr.write(`[remoting] ${localUrl}\n`);
|
|
200
|
+
|
|
201
|
+
// Start localhost.run tunnel for public access
|
|
202
|
+
// Browser opens only after tunnel is ready
|
|
203
|
+
startTunnel(localUrl);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
function startTunnel(localUrl) {
|
|
207
|
+
const tunnel = spawn('ssh', [
|
|
208
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
209
|
+
'-R', `80:localhost:${PORT}`,
|
|
210
|
+
'nokey@localhost.run',
|
|
211
|
+
], {
|
|
212
|
+
name: 'xterm-256color',
|
|
213
|
+
cols: 120,
|
|
214
|
+
rows: 24,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
let publicUrl = '';
|
|
218
|
+
let browserOpened = false;
|
|
219
|
+
|
|
220
|
+
tunnel.onData((data) => {
|
|
221
|
+
const text = data.toString();
|
|
222
|
+
|
|
223
|
+
// Match: "xxxxx.lhr.life tunneled with tls termination, https://xxxxx.lhr.life"
|
|
224
|
+
const match = text.match(/tunneled with tls termination,\s*(https:\/\/\S+)/);
|
|
225
|
+
if (match && !publicUrl) {
|
|
226
|
+
publicUrl = match[1];
|
|
227
|
+
const fullUrl = `${publicUrl}?session=${SESSION_ID}`;
|
|
228
|
+
process.stderr.write(`[remoting] ${fullUrl}\n\n`);
|
|
229
|
+
|
|
230
|
+
// Open browser with public URL (only once, after tunnel is ready)
|
|
231
|
+
if (!browserOpened) {
|
|
232
|
+
browserOpened = true;
|
|
233
|
+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
234
|
+
execFile(cmd, [fullUrl], (err) => {
|
|
235
|
+
if (err) {
|
|
236
|
+
process.stderr.write(`[remoting] Could not auto-open browser. Open manually: ${fullUrl}\n`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
tunnel.onExit(({ exitCode }) => {
|
|
244
|
+
if (!browserOpened) {
|
|
245
|
+
// Tunnel failed — fall back to opening local URL
|
|
246
|
+
process.stderr.write(`[remoting] Tunnel failed (code ${exitCode}). Opening local URL instead.\n`);
|
|
247
|
+
const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
248
|
+
execFile(cmd, [localUrl]);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Clean up tunnel on exit
|
|
253
|
+
process.on('exit', () => {
|
|
254
|
+
try { tunnel.kill(); } catch (_) {}
|
|
255
|
+
});
|
|
256
|
+
}
|