@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 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
@@ -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>
@@ -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
+ }