termbeam 1.8.1 → 1.9.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 +55 -68
- package/bin/termbeam.js +129 -0
- package/package.json +1 -1
- package/src/cli.js +15 -0
- package/src/client.js +169 -0
- package/src/resume.js +387 -0
- package/src/routes.js +2 -2
- package/src/server.js +46 -6
- package/src/sessions.js +1 -1
package/README.md
CHANGED
|
@@ -14,9 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
</div>
|
|
16
16
|
|
|
17
|
-
TermBeam lets you access your terminal from a phone, tablet, or any browser — no SSH, no port forwarding, no
|
|
18
|
-
|
|
19
|
-
I built this because I kept needing to run quick commands on my dev machine while away from my desk, and SSH on a phone is painful. TermBeam gives you a real terminal with a touch-optimized UI — key bar, swipe scroll, pinch zoom — that actually works on small screens. You get multi-session tabs with split view, terminal search, a command palette, 12 themes, and secure remote access out of the box.
|
|
17
|
+
TermBeam lets you access your terminal from a phone, tablet, or any browser — no SSH, no port forwarding, no configuration needed. Run one command and scan the QR code.
|
|
20
18
|
|
|
21
19
|
[Full documentation](https://dorlugasigal.github.io/TermBeam/) · [Website](https://termbeam.pages.dev)
|
|
22
20
|
|
|
@@ -47,90 +45,79 @@ termbeam
|
|
|
47
45
|
|
|
48
46
|
Scan the QR code printed in your terminal, or open the URL on any device.
|
|
49
47
|
|
|
50
|
-
> **First time?** Run `termbeam -i` for a guided setup wizard that walks you through password, port, and access mode.
|
|
51
|
-
|
|
52
|
-
### Secure by default
|
|
53
|
-
|
|
54
|
-
TermBeam starts with a tunnel and auto-generated password out of the box — just run `termbeam` and scan the QR code.
|
|
55
|
-
|
|
56
48
|
```bash
|
|
57
49
|
termbeam # tunnel + auto-password (default)
|
|
58
|
-
termbeam --password mysecret #
|
|
59
|
-
termbeam --no-tunnel # LAN
|
|
60
|
-
termbeam --no-password # disable password protection
|
|
50
|
+
termbeam --password mysecret # custom password
|
|
51
|
+
termbeam --no-tunnel # LAN only
|
|
61
52
|
termbeam -i # interactive setup wizard
|
|
62
53
|
```
|
|
63
54
|
|
|
64
|
-
##
|
|
55
|
+
## Features
|
|
65
56
|
|
|
66
|
-
|
|
67
|
-
# Tunnel is on by default
|
|
68
|
-
termbeam
|
|
57
|
+
### Mobile-First
|
|
69
58
|
|
|
70
|
-
|
|
71
|
-
|
|
59
|
+
- **No SSH client needed** — just open a browser on any device
|
|
60
|
+
- **Touch-optimized key bar** with arrows, Tab, Ctrl, Esc, copy, paste, and more
|
|
61
|
+
- **Swipe scrolling**, pinch zoom, and text selection overlay for copy-paste
|
|
62
|
+
- **iPhone PWA safe-area support** for a native-app feel
|
|
72
63
|
|
|
73
|
-
|
|
74
|
-
termbeam --no-tunnel
|
|
75
|
-
```
|
|
64
|
+
### Multi-Session
|
|
76
65
|
|
|
77
|
-
|
|
66
|
+
- **Tabbed terminals** with drag-to-reorder and live tab previews on hover/long-press
|
|
67
|
+
- **Split view** — two sessions side-by-side (auto-rotates horizontal/vertical)
|
|
68
|
+
- **Session colors and activity indicators** for at-a-glance status
|
|
69
|
+
- **Folder browser** for picking working directory, optional initial command per session
|
|
78
70
|
|
|
79
|
-
|
|
80
|
-
- **macOS:** `brew install --cask devtunnel`
|
|
81
|
-
- **Linux:** `curl -sL https://aka.ms/DevTunnelCliInstall | bash`
|
|
71
|
+
### Productivity
|
|
82
72
|
|
|
83
|
-
|
|
73
|
+
- **Terminal search** with regex, match count, and prev/next navigation
|
|
74
|
+
- **Command palette** (Ctrl+K / Cmd+K) for quick access to all actions
|
|
75
|
+
- **Completion notifications** — browser alerts when background commands finish
|
|
76
|
+
- **12 color themes** with adjustable font size
|
|
77
|
+
- **Port preview** — reverse-proxy a local web server through TermBeam
|
|
78
|
+
- **Image paste** from clipboard
|
|
84
79
|
|
|
85
|
-
|
|
80
|
+
### Secure by Default
|
|
86
81
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
82
|
+
- **Auto-generated password** with rate limiting and httpOnly cookies
|
|
83
|
+
- **QR code auto-login** with single-use share tokens (5-min expiry)
|
|
84
|
+
- **DevTunnel integration** for secure remote access — ephemeral or persisted URLs
|
|
85
|
+
- **Security headers** (X-Frame-Options, CSP, nosniff) on all responses; only detected shells allowed
|
|
86
|
+
|
|
87
|
+
## How It Works
|
|
88
|
+
|
|
89
|
+
TermBeam starts a lightweight web server that spawns a PTY (pseudo-terminal) with your shell, serves a mobile-optimized [xterm.js](https://xtermjs.org/) UI via Express, and bridges the two over WebSocket. Multiple clients can view the same session simultaneously, and sessions persist when all clients disconnect.
|
|
90
|
+
|
|
91
|
+
```mermaid
|
|
92
|
+
flowchart LR
|
|
93
|
+
A["Phone / Browser"] <-->|WebSocket| B["TermBeam Server"]
|
|
94
|
+
B <-->|PTY| C["Shell (zsh/bash)"]
|
|
95
|
+
B -->|Express| D["Web UI (xterm.js)"]
|
|
96
|
+
B -.->|Optional| E["DevTunnel"]
|
|
98
97
|
```
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
| --------------------- | ---------------------------------------------------- | -------------- |
|
|
102
|
-
| `--password <pw>` | Set access password (also accepts `--password=<pw>`) | Auto-generated |
|
|
103
|
-
| `--no-password` | Disable password (cannot combine with `--public`) | — |
|
|
104
|
-
| `--generate-password` | Auto-generate a secure password | On |
|
|
105
|
-
| `--tunnel` | Create an ephemeral devtunnel URL (private) | On |
|
|
106
|
-
| `--no-tunnel` | Disable tunnel (LAN-only) | — |
|
|
107
|
-
| `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
|
|
108
|
-
| `--public` | Allow public tunnel access | Off |
|
|
109
|
-
| `--port <port>` | Server port | `3456` |
|
|
110
|
-
| `--host <addr>` | Bind address | `127.0.0.1` |
|
|
111
|
-
| `--lan` | Bind to all interfaces (LAN access) | Off |
|
|
112
|
-
| `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
|
|
113
|
-
| `-i, --interactive` | Interactive setup wizard (guided configuration) | Off |
|
|
114
|
-
| `-h, --help` | Show help | — |
|
|
115
|
-
| `-v, --version` | Show version | — |
|
|
116
|
-
|
|
117
|
-
| Subcommand | Description |
|
|
118
|
-
| ------------------- | ----------------------------- |
|
|
119
|
-
| `service install` | Interactive PM2 service setup |
|
|
120
|
-
| `service uninstall` | Stop & remove from PM2 |
|
|
121
|
-
| `service status` | Show PM2 service status |
|
|
122
|
-
| `service logs` | Tail PM2 service logs |
|
|
123
|
-
| `service restart` | Restart PM2 service |
|
|
124
|
-
|
|
125
|
-
Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `TERMBEAM_LOG_LEVEL`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
|
|
99
|
+
## CLI Highlights
|
|
126
100
|
|
|
127
|
-
|
|
101
|
+
| Flag | Description | Default |
|
|
102
|
+
| --------------------- | ----------------------------------------------- | -------------- |
|
|
103
|
+
| `--password <pw>` | Set access password | Auto-generated |
|
|
104
|
+
| `--no-password` | Disable password protection | — |
|
|
105
|
+
| `--tunnel` | Create an ephemeral devtunnel URL | On |
|
|
106
|
+
| `--no-tunnel` | Disable tunnel (LAN-only) | — |
|
|
107
|
+
| `--persisted-tunnel` | Reusable devtunnel URL (stable across restarts) | Off |
|
|
108
|
+
| `--port <port>` | Server port | `3456` |
|
|
109
|
+
| `--host <addr>` | Bind address | `127.0.0.1` |
|
|
110
|
+
| `--lan` | Bind to all interfaces (LAN access) | Off |
|
|
111
|
+
| `-i, --interactive` | Interactive setup wizard | Off |
|
|
112
|
+
| `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
|
|
113
|
+
|
|
114
|
+
For all flags, subcommands, and environment variables, see the [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
|
|
128
115
|
|
|
129
|
-
|
|
116
|
+
## Security
|
|
130
117
|
|
|
131
|
-
Auth uses
|
|
118
|
+
TermBeam auto-generates a password and creates a secure tunnel by default, binding to `127.0.0.1` (localhost only). Auth uses httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, QR codes contain single-use share tokens (5-min expiry), and security headers (X-Frame-Options, CSP, nosniff) are set on all responses.
|
|
132
119
|
|
|
133
|
-
For the full threat model
|
|
120
|
+
For the full threat model and safety checklist, see [SECURITY.md](SECURITY.md). For detailed security documentation, see the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/).
|
|
134
121
|
|
|
135
122
|
## Contributing
|
|
136
123
|
|
package/bin/termbeam.js
CHANGED
|
@@ -8,13 +8,142 @@ if (subcommand === 'service') {
|
|
|
8
8
|
console.error(err.message);
|
|
9
9
|
process.exit(1);
|
|
10
10
|
});
|
|
11
|
+
} else if (subcommand === 'resume') {
|
|
12
|
+
const { resume } = require('../src/resume');
|
|
13
|
+
resume(process.argv.slice(3)).catch((err) => {
|
|
14
|
+
console.error(err.message);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
17
|
+
} else if (subcommand === 'list') {
|
|
18
|
+
const { list } = require('../src/resume');
|
|
19
|
+
list().catch((err) => {
|
|
20
|
+
console.error(err.message);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
});
|
|
11
23
|
} else {
|
|
24
|
+
// Reject any non-flag positional arg — it's not a known subcommand
|
|
25
|
+
if (subcommand && !subcommand.startsWith('-')) {
|
|
26
|
+
const { printHelp } = require('../src/cli');
|
|
27
|
+
console.error(`Unknown command: ${subcommand}\n`);
|
|
28
|
+
printHelp();
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
12
32
|
const { createTermBeamServer } = require('../src/server.js');
|
|
13
33
|
const { parseArgs } = require('../src/cli');
|
|
14
34
|
const { runInteractiveSetup } = require('../src/interactive');
|
|
35
|
+
const { readConnectionConfig } = require('../src/resume');
|
|
36
|
+
const http = require('http');
|
|
37
|
+
|
|
38
|
+
function httpPost(url, headers) {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
const req = http.request(
|
|
42
|
+
{
|
|
43
|
+
hostname: parsed.hostname,
|
|
44
|
+
port: parsed.port,
|
|
45
|
+
path: parsed.pathname,
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers,
|
|
48
|
+
timeout: 2000,
|
|
49
|
+
},
|
|
50
|
+
(res) => {
|
|
51
|
+
res.resume();
|
|
52
|
+
resolve(res.statusCode);
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
req.on('error', () => resolve(null));
|
|
56
|
+
req.on('timeout', () => {
|
|
57
|
+
req.destroy();
|
|
58
|
+
resolve(null);
|
|
59
|
+
});
|
|
60
|
+
req.end();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkExistingServer(config) {
|
|
65
|
+
if (!config) return Promise.resolve(false);
|
|
66
|
+
const host = config.host === 'localhost' ? '127.0.0.1' : config.host;
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const req = http.get(
|
|
69
|
+
`http://${host}:${config.port}/api/sessions`,
|
|
70
|
+
{
|
|
71
|
+
timeout: 2000,
|
|
72
|
+
headers: config.password ? { Authorization: `Bearer ${config.password}` } : {},
|
|
73
|
+
},
|
|
74
|
+
(res) => {
|
|
75
|
+
res.resume();
|
|
76
|
+
resolve(res.statusCode < 500);
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
req.on('error', () => resolve(false));
|
|
80
|
+
req.on('timeout', () => {
|
|
81
|
+
req.destroy();
|
|
82
|
+
resolve(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function stopExistingServer(config, fallbackPassword) {
|
|
88
|
+
// Always target loopback — the shutdown endpoint only accepts loopback requests
|
|
89
|
+
const url = `http://127.0.0.1:${config.port}/api/shutdown`;
|
|
90
|
+
console.log(`Stopping existing server on port ${config.port}...`);
|
|
91
|
+
|
|
92
|
+
// Try with config password, then fallback password, then no password
|
|
93
|
+
const passwords = [config.password, fallbackPassword, null].filter(
|
|
94
|
+
(v, i, a) => a.indexOf(v) === i,
|
|
95
|
+
);
|
|
96
|
+
let stopped = false;
|
|
97
|
+
for (const pw of passwords) {
|
|
98
|
+
const headers = pw ? { Authorization: `Bearer ${pw}` } : {};
|
|
99
|
+
const status = await httpPost(url, headers);
|
|
100
|
+
if (status && status !== 401) {
|
|
101
|
+
stopped = true;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!stopped) {
|
|
106
|
+
console.error(
|
|
107
|
+
'Cannot stop the existing server — password mismatch.\n' +
|
|
108
|
+
'Stop it manually (Ctrl+C in its terminal) and try again.',
|
|
109
|
+
);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
for (let i = 0; i < 20; i++) {
|
|
113
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
114
|
+
if (!(await checkExistingServer(config))) break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
15
117
|
|
|
16
118
|
async function main() {
|
|
17
119
|
const baseConfig = parseArgs();
|
|
120
|
+
const targetPort = baseConfig.port;
|
|
121
|
+
const targetHost = baseConfig.host === '0.0.0.0' ? '127.0.0.1' : baseConfig.host;
|
|
122
|
+
|
|
123
|
+
// Check connection.json for an existing server
|
|
124
|
+
const existing = readConnectionConfig();
|
|
125
|
+
if (existing && (await checkExistingServer(existing))) {
|
|
126
|
+
if (baseConfig.force) {
|
|
127
|
+
await stopExistingServer(existing, baseConfig.password);
|
|
128
|
+
} else {
|
|
129
|
+
const displayHost = existing.host === '127.0.0.1' ? 'localhost' : existing.host;
|
|
130
|
+
console.error(
|
|
131
|
+
`TermBeam is already running on http://${displayHost}:${existing.port}\n` +
|
|
132
|
+
'Use "termbeam resume" to reconnect, "termbeam list" to list sessions,\n' +
|
|
133
|
+
'or "termbeam --force" to stop the existing server and start a new one.',
|
|
134
|
+
);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Also check the target port directly (handles stale/missing connection.json)
|
|
140
|
+
if (baseConfig.force && targetPort !== 0) {
|
|
141
|
+
const targetConfig = { host: targetHost, port: targetPort, password: baseConfig.password };
|
|
142
|
+
if (await checkExistingServer(targetConfig)) {
|
|
143
|
+
await stopExistingServer(targetConfig);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
18
147
|
let config;
|
|
19
148
|
if (baseConfig.interactive) {
|
|
20
149
|
config = await runInteractiveSetup(baseConfig);
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -10,6 +10,8 @@ termbeam — Beam your terminal to any device
|
|
|
10
10
|
|
|
11
11
|
Usage:
|
|
12
12
|
termbeam [options] [shell] [args...]
|
|
13
|
+
termbeam resume [name] [options] Reconnect to a running session
|
|
14
|
+
termbeam list List running sessions
|
|
13
15
|
termbeam service <action> Manage as a background service (PM2)
|
|
14
16
|
|
|
15
17
|
Actions (service):
|
|
@@ -31,6 +33,7 @@ Options:
|
|
|
31
33
|
--host <addr> Bind address (default: 127.0.0.1)
|
|
32
34
|
--lan Bind to 0.0.0.0 (allow LAN access, default: localhost only)
|
|
33
35
|
--log-level <level> Set log verbosity: error, warn, info, debug (default: info)
|
|
36
|
+
--force Stop any existing server before starting a new one
|
|
34
37
|
-i, --interactive Interactive setup wizard (guided configuration)
|
|
35
38
|
-h, --help Show this help
|
|
36
39
|
-v, --version Show version
|
|
@@ -50,6 +53,8 @@ Examples:
|
|
|
50
53
|
termbeam /bin/bash Use bash instead of default shell
|
|
51
54
|
termbeam --interactive Guided setup wizard
|
|
52
55
|
termbeam service install Set up as background service (PM2)
|
|
56
|
+
termbeam resume Reconnect to an active session
|
|
57
|
+
termbeam list List all active sessions
|
|
53
58
|
|
|
54
59
|
Environment:
|
|
55
60
|
PORT Server port (default: 3456)
|
|
@@ -244,6 +249,7 @@ function parseArgs() {
|
|
|
244
249
|
let persistedTunnel = false;
|
|
245
250
|
let publicTunnel = false;
|
|
246
251
|
let interactive = false;
|
|
252
|
+
let force = false;
|
|
247
253
|
let explicitPassword = !!password;
|
|
248
254
|
|
|
249
255
|
const args = process.argv.slice(2);
|
|
@@ -288,6 +294,14 @@ function parseArgs() {
|
|
|
288
294
|
interactive = true;
|
|
289
295
|
} else if (args[i] === '--log-level' && args[i + 1]) {
|
|
290
296
|
logLevel = args[++i];
|
|
297
|
+
} else if (args[i].startsWith('--log-level=')) {
|
|
298
|
+
logLevel = args[i].split('=')[1];
|
|
299
|
+
} else if (args[i] === '--force') {
|
|
300
|
+
force = true;
|
|
301
|
+
} else if (args[i].startsWith('--')) {
|
|
302
|
+
console.error(`Unknown flag: ${args[i]}\n`);
|
|
303
|
+
printHelp();
|
|
304
|
+
process.exit(1);
|
|
291
305
|
} else {
|
|
292
306
|
filteredArgs.push(args[i]);
|
|
293
307
|
}
|
|
@@ -341,6 +355,7 @@ function parseArgs() {
|
|
|
341
355
|
version,
|
|
342
356
|
logLevel,
|
|
343
357
|
interactive,
|
|
358
|
+
force,
|
|
344
359
|
};
|
|
345
360
|
}
|
|
346
361
|
|
package/src/client.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
|
|
3
|
+
const DETACH_KEY = '\x02'; // Ctrl+B
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a terminal client that pipes stdin/stdout over WebSocket.
|
|
7
|
+
* Resolves when detached or session exits. Rejects on connection error.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} opts
|
|
10
|
+
* @param {string} opts.url WebSocket URL (ws://host:port/ws)
|
|
11
|
+
* @param {string} [opts.password] Server password (null for no-auth mode)
|
|
12
|
+
* @param {string} opts.sessionId Session ID to connect to
|
|
13
|
+
* @param {string} [opts.sessionName] Session name (for display)
|
|
14
|
+
* @param {string} [opts.detachKey] Key to detach (default: Ctrl+B)
|
|
15
|
+
* @returns {Promise<{reason: string}>}
|
|
16
|
+
*/
|
|
17
|
+
function createTerminalClient({
|
|
18
|
+
url,
|
|
19
|
+
password,
|
|
20
|
+
sessionId,
|
|
21
|
+
sessionName = 'session',
|
|
22
|
+
detachKey = DETACH_KEY,
|
|
23
|
+
detachLabel = 'Ctrl+B',
|
|
24
|
+
}) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const ws = new WebSocket(url);
|
|
27
|
+
let cleaned = false;
|
|
28
|
+
let bannerTimer = null;
|
|
29
|
+
let bannerShown = false;
|
|
30
|
+
let onData = null;
|
|
31
|
+
let onSigwinch = null;
|
|
32
|
+
|
|
33
|
+
function showBanner() {
|
|
34
|
+
if (!cleaned && !bannerShown) {
|
|
35
|
+
bannerShown = true;
|
|
36
|
+
process.stdout.write(
|
|
37
|
+
`\r\n\x1b[33m attached: ${sessionName} ─── ${detachLabel} to detach\x1b[0m\r\n\r\n`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
bannerTimer = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function debounceBanner() {
|
|
44
|
+
if (bannerShown) return;
|
|
45
|
+
if (bannerTimer) clearTimeout(bannerTimer);
|
|
46
|
+
bannerTimer = setTimeout(showBanner, 500);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resetTerminal() {
|
|
50
|
+
if (bannerTimer) clearTimeout(bannerTimer);
|
|
51
|
+
process.stdout.write('\x1b]0;\x07');
|
|
52
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
53
|
+
process.stdin.setRawMode(false);
|
|
54
|
+
}
|
|
55
|
+
if (onData) process.stdin.removeListener('data', onData);
|
|
56
|
+
process.stdin.pause();
|
|
57
|
+
if (onSigwinch) process.removeListener('SIGWINCH', onSigwinch);
|
|
58
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
59
|
+
ws.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cleanup(reason) {
|
|
64
|
+
if (cleaned) return;
|
|
65
|
+
cleaned = true;
|
|
66
|
+
resetTerminal();
|
|
67
|
+
resolve({ reason });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ws.on('open', () => {
|
|
71
|
+
if (password) {
|
|
72
|
+
ws.send(JSON.stringify({ type: 'auth', password }));
|
|
73
|
+
} else {
|
|
74
|
+
ws.send(JSON.stringify({ type: 'attach', sessionId }));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
ws.on('message', (raw) => {
|
|
79
|
+
let msg;
|
|
80
|
+
try {
|
|
81
|
+
msg = JSON.parse(raw);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (msg.type === 'auth_ok') {
|
|
87
|
+
ws.send(JSON.stringify({ type: 'attach', sessionId }));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (msg.type === 'attached') {
|
|
92
|
+
// Set terminal title to show we're attached
|
|
93
|
+
process.stdout.write(`\x1b]0;[termbeam] ${sessionName} — ${detachLabel} to detach\x07`);
|
|
94
|
+
|
|
95
|
+
const refs = {};
|
|
96
|
+
enterRawMode(ws, detachKey, cleanup, refs);
|
|
97
|
+
onData = refs.onData;
|
|
98
|
+
onSigwinch = refs.onSigwinch;
|
|
99
|
+
sendResize(ws);
|
|
100
|
+
debounceBanner();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (msg.type === 'output') {
|
|
105
|
+
debounceBanner();
|
|
106
|
+
process.stdout.write(msg.data);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (msg.type === 'exit') {
|
|
111
|
+
cleanup(`session exited with code ${msg.code}`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (msg.type === 'error') {
|
|
116
|
+
cleanup(`error: ${msg.message}`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
ws.on('error', (err) => {
|
|
122
|
+
if (!cleaned) {
|
|
123
|
+
cleaned = true;
|
|
124
|
+
resetTerminal();
|
|
125
|
+
reject(err);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
ws.on('close', () => {
|
|
130
|
+
cleanup('connection closed');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function enterRawMode(ws, detachKey, cleanup, refs) {
|
|
136
|
+
if (process.stdin.isTTY) {
|
|
137
|
+
process.stdin.setRawMode(true);
|
|
138
|
+
}
|
|
139
|
+
process.stdin.resume();
|
|
140
|
+
|
|
141
|
+
refs.onData = (data) => {
|
|
142
|
+
const str = data.toString();
|
|
143
|
+
if (str === detachKey) {
|
|
144
|
+
cleanup('detached');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
148
|
+
ws.send(JSON.stringify({ type: 'input', data: str }));
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
process.stdin.on('data', refs.onData);
|
|
152
|
+
|
|
153
|
+
refs.onSigwinch = () => sendResize(ws);
|
|
154
|
+
process.on('SIGWINCH', refs.onSigwinch);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sendResize(ws) {
|
|
158
|
+
if (ws.readyState === WebSocket.OPEN && process.stdout.columns && process.stdout.rows) {
|
|
159
|
+
ws.send(
|
|
160
|
+
JSON.stringify({
|
|
161
|
+
type: 'resize',
|
|
162
|
+
cols: process.stdout.columns,
|
|
163
|
+
rows: process.stdout.rows,
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { createTerminalClient };
|
package/src/resume.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { createTerminalClient } = require('./client');
|
|
6
|
+
const { bold, dim, red, yellow, choose, createRL, ask } = require('./prompts');
|
|
7
|
+
|
|
8
|
+
const CONFIG_DIR = process.env.TERMBEAM_CONFIG_DIR || path.join(os.homedir(), '.termbeam');
|
|
9
|
+
const CONNECTION_FILE = path.join(CONFIG_DIR, 'connection.json');
|
|
10
|
+
|
|
11
|
+
// ── Connection config ────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function readConnectionConfig() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(CONNECTION_FILE, 'utf8'));
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeConnectionConfig({ port, host, password }) {
|
|
22
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
23
|
+
fs.writeFileSync(CONNECTION_FILE, JSON.stringify({ port, host, password }, null, 2) + '\n', {
|
|
24
|
+
mode: 0o600,
|
|
25
|
+
});
|
|
26
|
+
// Ensure restrictive permissions even if the file already existed
|
|
27
|
+
if (process.platform !== 'win32') {
|
|
28
|
+
try {
|
|
29
|
+
fs.chmodSync(CONNECTION_FILE, 0o600);
|
|
30
|
+
} catch {
|
|
31
|
+
/* best-effort */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function removeConnectionConfig() {
|
|
37
|
+
try {
|
|
38
|
+
fs.unlinkSync(CONNECTION_FILE);
|
|
39
|
+
} catch {
|
|
40
|
+
/* ignore */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Arg parsing ──────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function parseDetachKey(value) {
|
|
47
|
+
// \xNN hex escape → control character
|
|
48
|
+
const hexMatch = value.match(/^\\x([0-9a-fA-F]{2})$/);
|
|
49
|
+
if (hexMatch) return String.fromCharCode(parseInt(hexMatch[1], 16));
|
|
50
|
+
// ^X or ctrl+X → control character
|
|
51
|
+
const caretMatch = value.match(/^\^([A-Za-z])$/);
|
|
52
|
+
if (caretMatch) return String.fromCharCode(caretMatch[1].toUpperCase().charCodeAt(0) - 64);
|
|
53
|
+
const ctrlMatch = value.match(/^ctrl\+([A-Za-z])$/i);
|
|
54
|
+
if (ctrlMatch) return String.fromCharCode(ctrlMatch[1].toUpperCase().charCodeAt(0) - 64);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseResumeArgs(args) {
|
|
59
|
+
let name = null;
|
|
60
|
+
let port = null;
|
|
61
|
+
let host = null;
|
|
62
|
+
let password = null;
|
|
63
|
+
let detachKey = null;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < args.length; i++) {
|
|
66
|
+
if (args[i] === '--port' && args[i + 1]) {
|
|
67
|
+
port = parseInt(args[++i], 10);
|
|
68
|
+
} else if (args[i] === '--host' && args[i + 1]) {
|
|
69
|
+
host = args[++i];
|
|
70
|
+
} else if (args[i] === '--password' && args[i + 1]) {
|
|
71
|
+
password = args[++i];
|
|
72
|
+
} else if (args[i].startsWith('--password=')) {
|
|
73
|
+
password = args[i].split('=')[1];
|
|
74
|
+
} else if (args[i] === '--detach-key' && args[i + 1]) {
|
|
75
|
+
detachKey = parseDetachKey(args[++i]);
|
|
76
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
77
|
+
return { help: true };
|
|
78
|
+
} else if (args[i].startsWith('--')) {
|
|
79
|
+
console.error(`Unknown flag: ${args[i]}`);
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return { help: true };
|
|
82
|
+
} else if (!args[i].startsWith('-')) {
|
|
83
|
+
name = args[i];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { name, port, host, password, detachKey };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function httpRequest(urlStr, options = {}) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const url = new URL(urlStr);
|
|
95
|
+
const reqOpts = {
|
|
96
|
+
hostname: url.hostname,
|
|
97
|
+
port: url.port,
|
|
98
|
+
path: url.pathname,
|
|
99
|
+
method: options.method || 'GET',
|
|
100
|
+
headers: { ...options.headers },
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const req = http.request(reqOpts, (res) => {
|
|
104
|
+
let body = '';
|
|
105
|
+
res.on('data', (chunk) => (body += chunk));
|
|
106
|
+
res.on('end', () => resolve({ status: res.statusCode, body, headers: res.headers }));
|
|
107
|
+
});
|
|
108
|
+
req.on('error', reject);
|
|
109
|
+
if (options.body) req.write(options.body);
|
|
110
|
+
req.end();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function fetchSessions(baseUrl, password) {
|
|
115
|
+
const headers = {};
|
|
116
|
+
if (password) headers.Authorization = `Bearer ${password}`;
|
|
117
|
+
|
|
118
|
+
const res = await httpRequest(`${baseUrl}/api/sessions`, { headers });
|
|
119
|
+
if (res.status === 401) {
|
|
120
|
+
throw new Error('unauthorized');
|
|
121
|
+
}
|
|
122
|
+
if (res.status >= 400) {
|
|
123
|
+
throw new Error(`HTTP ${res.status}: ${res.body}`);
|
|
124
|
+
}
|
|
125
|
+
return JSON.parse(res.body);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Formatting ───────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
function formatUptime(createdAt) {
|
|
131
|
+
const ms = Date.now() - new Date(createdAt).getTime();
|
|
132
|
+
const secs = Math.floor(ms / 1000);
|
|
133
|
+
if (secs < 60) return `${secs}s`;
|
|
134
|
+
const mins = Math.floor(secs / 60);
|
|
135
|
+
if (mins < 60) return `${mins}m`;
|
|
136
|
+
const hours = Math.floor(mins / 60);
|
|
137
|
+
if (hours < 24) return `${hours}h ${mins % 60}m`;
|
|
138
|
+
const days = Math.floor(hours / 24);
|
|
139
|
+
return `${days}d ${hours % 24}h`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function shortId(id) {
|
|
143
|
+
return id.slice(0, 8);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Help text ────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function detachKeyLabel(key) {
|
|
149
|
+
if (!key || key === '\x02') return 'Ctrl+B';
|
|
150
|
+
if (key.length === 1 && key.charCodeAt(0) < 27) {
|
|
151
|
+
return `Ctrl+${String.fromCharCode(key.charCodeAt(0) + 64)}`;
|
|
152
|
+
}
|
|
153
|
+
return key;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function printResumeHelp() {
|
|
157
|
+
console.log(`
|
|
158
|
+
${bold('termbeam resume')} — Reconnect to a running session
|
|
159
|
+
|
|
160
|
+
${bold('Usage:')}
|
|
161
|
+
termbeam resume [name] [options]
|
|
162
|
+
|
|
163
|
+
${bold('Arguments:')}
|
|
164
|
+
name Session name to connect to (auto-selects if unique match)
|
|
165
|
+
|
|
166
|
+
${bold('Options:')}
|
|
167
|
+
--port <port> Server port (default: from ~/.termbeam/connection.json or 3456)
|
|
168
|
+
--host <host> Server host (default: from config or localhost)
|
|
169
|
+
--password <pw> Server password (default: from config or prompt)
|
|
170
|
+
--detach-key <key> Detach key combo (default: Ctrl+B)
|
|
171
|
+
-h, --help Show this help
|
|
172
|
+
|
|
173
|
+
${bold('Examples:')}
|
|
174
|
+
termbeam resume Select from running sessions
|
|
175
|
+
termbeam resume my-project Connect to session named "my-project"
|
|
176
|
+
termbeam resume --port 4000 Connect to server on port 4000
|
|
177
|
+
|
|
178
|
+
${dim('Press Ctrl+B to detach from a session without closing it.')}
|
|
179
|
+
`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Commands ─────────────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
async function resolveConnection(args) {
|
|
185
|
+
const opts = parseResumeArgs(args);
|
|
186
|
+
if (opts.help) return { help: true };
|
|
187
|
+
|
|
188
|
+
const saved = readConnectionConfig();
|
|
189
|
+
const host = opts.host || (saved && saved.host) || 'localhost';
|
|
190
|
+
const port = opts.port || (saved && saved.port) || 3456;
|
|
191
|
+
let password = opts.password || (saved && saved.password) || null;
|
|
192
|
+
const connHost = host === 'localhost' ? '127.0.0.1' : host;
|
|
193
|
+
const baseUrl = `http://${connHost}:${port}`;
|
|
194
|
+
const displayUrl = `http://${connHost === '127.0.0.1' ? 'localhost' : connHost}:${port}`;
|
|
195
|
+
|
|
196
|
+
// Try to fetch sessions, handle auth errors
|
|
197
|
+
let sessions;
|
|
198
|
+
try {
|
|
199
|
+
sessions = await fetchSessions(baseUrl, password);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
if (err.message === 'unauthorized') {
|
|
202
|
+
// Prompt for password if none was explicitly provided
|
|
203
|
+
if (!opts.password) {
|
|
204
|
+
const rl = createRL();
|
|
205
|
+
password = await ask(rl, ` Password for ${displayUrl}:`);
|
|
206
|
+
rl.close();
|
|
207
|
+
try {
|
|
208
|
+
sessions = await fetchSessions(baseUrl, password);
|
|
209
|
+
} catch {
|
|
210
|
+
console.error(red(' Authentication failed.'));
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
console.error(red(' Authentication failed.'));
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
} else if (err.code === 'ECONNREFUSED') {
|
|
218
|
+
return { refused: true, displayUrl };
|
|
219
|
+
} else {
|
|
220
|
+
throw err;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { host, port, password, baseUrl, displayUrl, sessions, opts };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function resume(args) {
|
|
228
|
+
if (process.env.TERMBEAM_SESSION) {
|
|
229
|
+
console.error(red(' Already inside a TermBeam session. Detach first (Ctrl+B).'));
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const conn = await resolveConnection(args);
|
|
234
|
+
if (conn.help) {
|
|
235
|
+
printResumeHelp();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (conn.refused) {
|
|
239
|
+
console.error(red(' No TermBeam server is running.'));
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const { host, port, password, sessions, opts } = conn;
|
|
244
|
+
|
|
245
|
+
if (sessions.length === 0) {
|
|
246
|
+
console.error(red(' No active sessions on the server.'));
|
|
247
|
+
process.exit(1);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let session;
|
|
251
|
+
|
|
252
|
+
if (opts.name) {
|
|
253
|
+
// Match by name (case-insensitive) or by ID prefix
|
|
254
|
+
session =
|
|
255
|
+
sessions.find((s) => s.name.toLowerCase() === opts.name.toLowerCase()) ||
|
|
256
|
+
sessions.find((s) => s.id.startsWith(opts.name));
|
|
257
|
+
|
|
258
|
+
if (!session) {
|
|
259
|
+
console.error(red(` No session matching "${opts.name}".`));
|
|
260
|
+
console.log(dim(' Available sessions:'));
|
|
261
|
+
for (const s of sessions) {
|
|
262
|
+
console.log(dim(` ${s.name} (${shortId(s.id)})`));
|
|
263
|
+
}
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
} else if (sessions.length === 1) {
|
|
267
|
+
session = sessions[0];
|
|
268
|
+
} else {
|
|
269
|
+
// Interactive chooser
|
|
270
|
+
const rl = createRL();
|
|
271
|
+
const choices = sessions.map((s) => ({
|
|
272
|
+
label: `${s.name} ${dim(shortId(s.id))}`,
|
|
273
|
+
hint: `${s.cwd} · ${formatUptime(s.createdAt)} · ${s.clients} client${s.clients !== 1 ? 's' : ''}`,
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
console.log('');
|
|
277
|
+
const { index } = await choose(rl, ` ${bold('Select a session:')}`, choices);
|
|
278
|
+
rl.close();
|
|
279
|
+
session = sessions[index];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const wsHost = host === 'localhost' ? '127.0.0.1' : host;
|
|
283
|
+
const wsUrl = `ws://${wsHost}:${port}/ws`;
|
|
284
|
+
const detachKey = opts.detachKey || '\x02';
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const { reason } = await createTerminalClient({
|
|
288
|
+
url: wsUrl,
|
|
289
|
+
password,
|
|
290
|
+
sessionId: session.id,
|
|
291
|
+
sessionName: session.name,
|
|
292
|
+
detachKey,
|
|
293
|
+
detachLabel: detachKeyLabel(detachKey),
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
console.log('');
|
|
297
|
+
if (reason === 'detached') {
|
|
298
|
+
console.log(yellow(` Detached from ${bold(session.name)}.`));
|
|
299
|
+
} else if (reason && reason.startsWith('session exited')) {
|
|
300
|
+
console.log(dim(` Session ${bold(session.name)} ended.`));
|
|
301
|
+
} else {
|
|
302
|
+
console.log(dim(` Disconnected from ${bold(session.name)}.`));
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.error(red(` Connection failed: ${err.message}`));
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function list() {
|
|
311
|
+
const saved = readConnectionConfig();
|
|
312
|
+
const host = (saved && saved.host) || 'localhost';
|
|
313
|
+
const port = (saved && saved.port) || 3456;
|
|
314
|
+
let password = (saved && saved.password) || null;
|
|
315
|
+
const connHost = host === 'localhost' ? '127.0.0.1' : host;
|
|
316
|
+
const baseUrl = `http://${connHost}:${port}`;
|
|
317
|
+
const displayUrl = `http://${connHost === '127.0.0.1' ? 'localhost' : connHost}:${port}`;
|
|
318
|
+
|
|
319
|
+
let sessions;
|
|
320
|
+
try {
|
|
321
|
+
sessions = await fetchSessions(baseUrl, password);
|
|
322
|
+
} catch (err) {
|
|
323
|
+
if (err.message === 'unauthorized') {
|
|
324
|
+
if (!password) {
|
|
325
|
+
const rl = createRL();
|
|
326
|
+
password = await ask(rl, ` Password for ${displayUrl}:`);
|
|
327
|
+
rl.close();
|
|
328
|
+
try {
|
|
329
|
+
sessions = await fetchSessions(baseUrl, password);
|
|
330
|
+
} catch {
|
|
331
|
+
console.error(red(' Authentication failed.'));
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
console.error(red(' Authentication failed.'));
|
|
336
|
+
process.exit(1);
|
|
337
|
+
}
|
|
338
|
+
} else if (err.code === 'ECONNREFUSED') {
|
|
339
|
+
console.log(dim(' No TermBeam server is running.'));
|
|
340
|
+
return;
|
|
341
|
+
} else {
|
|
342
|
+
console.error(red(` Connection failed: ${err.message}`));
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (sessions.length === 0) {
|
|
348
|
+
console.log(dim(' No active sessions.'));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log('');
|
|
353
|
+
console.log(
|
|
354
|
+
bold(` ${sessions.length} session${sessions.length !== 1 ? 's' : ''} on ${displayUrl}`),
|
|
355
|
+
);
|
|
356
|
+
console.log('');
|
|
357
|
+
|
|
358
|
+
// Table header
|
|
359
|
+
const nameW = Math.max(6, ...sessions.map((s) => s.name.length));
|
|
360
|
+
const cwdW = Math.max(4, ...sessions.map((s) => s.cwd.length));
|
|
361
|
+
|
|
362
|
+
console.log(
|
|
363
|
+
dim(
|
|
364
|
+
` ${'NAME'.padEnd(nameW)} ${'ID'.padEnd(8)} ${'CWD'.padEnd(cwdW)} ${'UPTIME'.padEnd(8)} CLIENTS`,
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
for (const s of sessions) {
|
|
369
|
+
const uptime = formatUptime(s.createdAt);
|
|
370
|
+
console.log(
|
|
371
|
+
` ${bold(s.name.padEnd(nameW))} ${dim(shortId(s.id).padEnd(8))} ${s.cwd.padEnd(cwdW)} ${uptime.padEnd(8)} ${s.clients}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
console.log('');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = {
|
|
378
|
+
resume,
|
|
379
|
+
list,
|
|
380
|
+
writeConnectionConfig,
|
|
381
|
+
removeConnectionConfig,
|
|
382
|
+
readConnectionConfig,
|
|
383
|
+
printResumeHelp,
|
|
384
|
+
parseDetachKey,
|
|
385
|
+
CONFIG_DIR,
|
|
386
|
+
CONNECTION_FILE,
|
|
387
|
+
};
|
package/src/routes.js
CHANGED
|
@@ -69,7 +69,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
69
69
|
const token = auth.generateToken();
|
|
70
70
|
res.cookie('pty_token', token, {
|
|
71
71
|
httpOnly: true,
|
|
72
|
-
sameSite: '
|
|
72
|
+
sameSite: 'strict',
|
|
73
73
|
maxAge: 24 * 60 * 60 * 1000,
|
|
74
74
|
secure: req.secure,
|
|
75
75
|
});
|
|
@@ -99,7 +99,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
99
99
|
const token = auth.generateToken();
|
|
100
100
|
res.cookie('pty_token', token, {
|
|
101
101
|
httpOnly: true,
|
|
102
|
-
sameSite: '
|
|
102
|
+
sameSite: 'strict',
|
|
103
103
|
maxAge: 24 * 60 * 60 * 1000,
|
|
104
104
|
secure: req.secure,
|
|
105
105
|
});
|
package/src/server.js
CHANGED
|
@@ -15,6 +15,7 @@ const { setupRoutes, cleanupUploadedFiles } = require('./routes');
|
|
|
15
15
|
const { setupWebSocket } = require('./websocket');
|
|
16
16
|
const { startTunnel, cleanupTunnel, findDevtunnel } = require('./tunnel');
|
|
17
17
|
const { createPreviewProxy } = require('./preview');
|
|
18
|
+
const { writeConnectionConfig, removeConnectionConfig } = require('./resume');
|
|
18
19
|
|
|
19
20
|
// --- Helpers ---
|
|
20
21
|
function getLocalIP() {
|
|
@@ -85,10 +86,30 @@ function createTermBeamServer(overrides = {}) {
|
|
|
85
86
|
sessions.shutdown();
|
|
86
87
|
cleanupUploadedFiles();
|
|
87
88
|
cleanupTunnel();
|
|
89
|
+
removeConnectionConfig();
|
|
88
90
|
server.close();
|
|
89
91
|
wss.close();
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
// Shutdown endpoint for --force (loopback only)
|
|
95
|
+
app.post('/api/shutdown', auth.middleware, (req, res) => {
|
|
96
|
+
const remoteAddress = req.socket && req.socket.remoteAddress;
|
|
97
|
+
if (
|
|
98
|
+
remoteAddress !== '127.0.0.1' &&
|
|
99
|
+
remoteAddress !== '::1' &&
|
|
100
|
+
remoteAddress !== '::ffff:127.0.0.1'
|
|
101
|
+
) {
|
|
102
|
+
res.status(403).json({ error: 'Shutdown is only available from localhost' });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
res.json({ ok: true });
|
|
106
|
+
console.log('\n[termbeam] Shutdown requested by another instance. Goodbye!');
|
|
107
|
+
setTimeout(() => {
|
|
108
|
+
shutdown();
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}, 100);
|
|
111
|
+
});
|
|
112
|
+
|
|
92
113
|
async function start() {
|
|
93
114
|
// If tunnel mode is on but devtunnel is missing, offer to install it
|
|
94
115
|
if (config.useTunnel && !findDevtunnel()) {
|
|
@@ -134,8 +155,27 @@ function createTermBeamServer(overrides = {}) {
|
|
|
134
155
|
|
|
135
156
|
return new Promise((resolve) => {
|
|
136
157
|
server.listen(config.port, config.host, async () => {
|
|
158
|
+
const actualPort = server.address().port;
|
|
137
159
|
const ip = getLocalIP();
|
|
138
|
-
const localUrl = `http://${ip}:${
|
|
160
|
+
const localUrl = `http://${ip}:${actualPort}`;
|
|
161
|
+
|
|
162
|
+
// Save connection info for `termbeam resume` auto-discovery
|
|
163
|
+
try {
|
|
164
|
+
const connHost =
|
|
165
|
+
config.host === '0.0.0.0' ||
|
|
166
|
+
config.host === '127.0.0.1' ||
|
|
167
|
+
config.host === '::' ||
|
|
168
|
+
config.host === '::1'
|
|
169
|
+
? 'localhost'
|
|
170
|
+
: config.host;
|
|
171
|
+
writeConnectionConfig({
|
|
172
|
+
port: actualPort,
|
|
173
|
+
host: connHost,
|
|
174
|
+
password: config.password || null,
|
|
175
|
+
});
|
|
176
|
+
} catch {
|
|
177
|
+
/* non-critical — resume will fall back to defaults */
|
|
178
|
+
}
|
|
139
179
|
|
|
140
180
|
const defaultId = sessions.create({
|
|
141
181
|
name: path.basename(config.cwd),
|
|
@@ -170,7 +210,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
170
210
|
console.log('');
|
|
171
211
|
const isLanReachable =
|
|
172
212
|
config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
|
|
173
|
-
state.shareBaseUrl = isLanReachable ? localUrl : `http://localhost:${
|
|
213
|
+
state.shareBaseUrl = isLanReachable ? localUrl : `http://localhost:${actualPort}`;
|
|
174
214
|
const gn = '\x1b[38;5;114m'; // green
|
|
175
215
|
const _dm = '\x1b[2m'; // dim
|
|
176
216
|
|
|
@@ -178,7 +218,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
178
218
|
|
|
179
219
|
let publicUrl = null;
|
|
180
220
|
if (config.useTunnel) {
|
|
181
|
-
const tunnel = await startTunnel(
|
|
221
|
+
const tunnel = await startTunnel(actualPort, {
|
|
182
222
|
persisted: config.persistedTunnel,
|
|
183
223
|
anonymous: config.publicTunnel,
|
|
184
224
|
});
|
|
@@ -203,12 +243,12 @@ function createTermBeamServer(overrides = {}) {
|
|
|
203
243
|
if (publicUrl) {
|
|
204
244
|
console.log(` Public: ${bl}${publicUrl}${rs}`);
|
|
205
245
|
}
|
|
206
|
-
console.log(` Local: http://localhost:${
|
|
246
|
+
console.log(` Local: http://localhost:${actualPort}`);
|
|
207
247
|
if (isLanReachable) {
|
|
208
248
|
console.log(` LAN: ${localUrl}`);
|
|
209
249
|
}
|
|
210
250
|
|
|
211
|
-
const qrUrl = publicUrl || (isLanReachable ? localUrl : `http://localhost:${
|
|
251
|
+
const qrUrl = publicUrl || (isLanReachable ? localUrl : `http://localhost:${actualPort}`);
|
|
212
252
|
const qrDisplayUrl = qrUrl; // clean URL shown in console text
|
|
213
253
|
const qrCodeUrl = config.password ? `${qrUrl}?ott=${auth.generateShareToken()}` : qrUrl;
|
|
214
254
|
console.log('');
|
|
@@ -223,7 +263,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
223
263
|
if (config.password) process.stdout.write(` Password: ${gn}${config.password}${rs}\n`);
|
|
224
264
|
console.log('');
|
|
225
265
|
|
|
226
|
-
resolve({ url: `http://localhost:${
|
|
266
|
+
resolve({ url: `http://localhost:${actualPort}`, defaultId });
|
|
227
267
|
});
|
|
228
268
|
});
|
|
229
269
|
}
|
package/src/sessions.js
CHANGED