termbeam 0.0.6 โ 0.0.8
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 +53 -115
- package/package.json +3 -1
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon.svg +6 -0
- package/public/index.html +6 -0
- package/public/manifest.json +14 -0
- package/public/sw.js +54 -0
- package/public/terminal.html +137 -1
- package/src/cli.js +7 -2
- package/src/routes.js +4 -0
- package/src/server.js +11 -4
- package/src/tunnel.js +105 -13
package/README.md
CHANGED
|
@@ -1,40 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
# TermBeam
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
**Beam your terminal to any device**
|
|
3
|
+
**Beam your terminal to any device.**
|
|
6
4
|
|
|
7
5
|
[](https://www.npmjs.com/package/termbeam)
|
|
8
6
|
[](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
|
|
7
|
+
[](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
|
|
9
8
|
[](https://opensource.org/licenses/MIT)
|
|
10
|
-
[](https://nodejs.org)
|
|
11
|
-
|
|
12
|
-
Access your terminal from your phone, tablet, or any browser.
|
|
13
|
-
Multi-session, mobile-optimized, with touch controls.
|
|
14
9
|
|
|
15
|
-
|
|
10
|
+
TermBeam lets you access your terminal from a phone, tablet, or any browser โ no SSH, no port forwarding, no config files. Run one command and scan the QR code.
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
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-friendly UI that actually works on small screens.
|
|
18
13
|
|
|
19
|
-
|
|
14
|
+
[Full documentation](https://dorlugasigal.github.io/TermBeam/)
|
|
20
15
|
|
|
21
16
|
https://github.com/user-attachments/assets/c91ca15d-0c84-400f-bbfa-3d58d1be07ee
|
|
22
17
|
|
|
23
|
-
##
|
|
24
|
-
|
|
25
|
-
- ๐ฑ **Mobile-first UI** โ Touch-friendly interface designed for phones and tablets
|
|
26
|
-
- ๐ฅ๏ธ **Multi-session** โ Run multiple terminal sessions simultaneously
|
|
27
|
-
- ๐ **Password auth** โ Token-based authentication with rate limiting
|
|
28
|
-
- ๐ **Folder browser** โ Visual directory picker with breadcrumb navigation
|
|
29
|
-
- ๐ **Touch controls** โ Arrow keys, Ctrl shortcuts, Tab, Esc via on-screen touch bar
|
|
30
|
-
- ๐ค **Nerd Font support** โ Full glyph rendering with JetBrains Mono Nerd Font
|
|
31
|
-
- ๐ฒ **QR code** โ Scan to connect instantly from your phone
|
|
32
|
-
- ๐ **DevTunnel** โ Optional public URL for remote access from anywhere
|
|
33
|
-
- ๐ **Adjustable font size** โ Pinch or button zoom for any screen
|
|
34
|
-
- โ๏ธ **Swipe to delete** โ iOS-style session management
|
|
35
|
-
- ๐ **Smart versioning** โ Shows git hash in dev, clean version from npm
|
|
36
|
-
|
|
37
|
-
## ๐ Quick Start
|
|
18
|
+
## Quick Start
|
|
38
19
|
|
|
39
20
|
```bash
|
|
40
21
|
npx termbeam
|
|
@@ -47,124 +28,81 @@ npm install -g termbeam
|
|
|
47
28
|
termbeam
|
|
48
29
|
```
|
|
49
30
|
|
|
50
|
-
|
|
31
|
+
Scan the QR code printed in your terminal, or open the URL on any device.
|
|
51
32
|
|
|
52
|
-
###
|
|
33
|
+
### Password protection (recommended)
|
|
53
34
|
|
|
54
35
|
```bash
|
|
55
|
-
# Auto-generate a secure password
|
|
56
36
|
termbeam --generate-password
|
|
57
37
|
|
|
58
|
-
#
|
|
38
|
+
# or set your own
|
|
59
39
|
termbeam --password mysecret
|
|
60
40
|
```
|
|
61
41
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
termbeam --tunnel --generate-password
|
|
66
|
-
```
|
|
42
|
+
## Features
|
|
67
43
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
44
|
+
- **Mobile-first UI** with on-screen touch bar (arrow keys, Tab, Ctrl shortcuts, Esc) and swipe-to-delete session management
|
|
45
|
+
- **Multiple sessions** running simultaneously, managed from a single hub page โ shows connected client count per session
|
|
46
|
+
- **Password auth** with token-based cookies and rate-limited login
|
|
47
|
+
- **Folder browser** to pick working directories without typing paths
|
|
48
|
+
- **Initial command** โ optionally launch a session straight into `htop`, `vim`, or any command
|
|
49
|
+
- **Shell detection** โ auto-detects your shell on all platforms (PowerShell, cmd, bash, zsh, Git Bash, WSL)
|
|
50
|
+
- **QR code on startup** for instant phone connection
|
|
51
|
+
- **Light/dark theme** with persistent preference
|
|
52
|
+
- **Adjustable font size** via status bar controls, saved across sessions
|
|
53
|
+
- **Remote access via [DevTunnel](#remote-access)** โ ephemeral or persisted public URLs
|
|
73
54
|
|
|
74
|
-
##
|
|
55
|
+
## Remote Access
|
|
75
56
|
|
|
76
57
|
```bash
|
|
77
|
-
#
|
|
78
|
-
termbeam
|
|
79
|
-
|
|
80
|
-
# Use a specific shell
|
|
81
|
-
termbeam /bin/bash
|
|
82
|
-
|
|
83
|
-
# Custom port and listen on all interfaces (LAN access)
|
|
84
|
-
termbeam --port 8080 --host 0.0.0.0
|
|
85
|
-
|
|
86
|
-
# Public tunnel + password (access from anywhere)
|
|
58
|
+
# One-off tunnel (deleted on shutdown)
|
|
87
59
|
termbeam --tunnel --generate-password
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
### CLI Options
|
|
91
|
-
|
|
92
|
-
| Flag | Description | Default |
|
|
93
|
-
| --------------------- | ------------------------------- | ----------- |
|
|
94
|
-
| `--password <pw>` | Set access password | None |
|
|
95
|
-
| `--generate-password` | Auto-generate a secure password | โ |
|
|
96
|
-
| `--tunnel` | Create a public devtunnel URL | Off |
|
|
97
|
-
| `--port <port>` | Server port | `3456` |
|
|
98
|
-
| `--host <addr>` | Bind address | `127.0.0.1` |
|
|
99
|
-
| `-h, --help` | Show help | โ |
|
|
100
|
-
| `-v, --version` | Show version | โ |
|
|
101
60
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
| ------------------- | ------------------------------- |
|
|
106
|
-
| `PORT` | Server port (overrides default) |
|
|
107
|
-
| `TERMBEAM_PASSWORD` | Access password |
|
|
108
|
-
| `TERMBEAM_CWD` | Default working directory |
|
|
109
|
-
|
|
110
|
-
## ๐ Security
|
|
61
|
+
# Persisted tunnel (stable URL you can bookmark, reused across restarts, 30-day expiry)
|
|
62
|
+
termbeam --persisted-tunnel --generate-password
|
|
63
|
+
```
|
|
111
64
|
|
|
112
|
-
|
|
65
|
+
Requires the [Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started):
|
|
113
66
|
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
-
|
|
117
|
-
- ๐ **Localhost by default** โ requires explicit `--host 0.0.0.0` for LAN access
|
|
67
|
+
- **Windows:** `winget install Microsoft.devtunnel`
|
|
68
|
+
- **macOS:** `brew install --cask devtunnel`
|
|
69
|
+
- **Linux:** `curl -sL https://aka.ms/DevTunnelCliInstall | bash`
|
|
118
70
|
|
|
119
|
-
|
|
71
|
+
Persisted tunnels save a tunnel ID to `~/.termbeam/tunnel.json` so the URL stays the same between sessions.
|
|
120
72
|
|
|
121
|
-
##
|
|
73
|
+
## CLI Reference
|
|
122
74
|
|
|
123
|
-
```
|
|
124
|
-
termbeam
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
โ โโโ server.js # Main orchestrator
|
|
128
|
-
โ โโโ cli.js # Argument parsing & help
|
|
129
|
-
โ โโโ auth.js # Authentication & rate limiting
|
|
130
|
-
โ โโโ sessions.js # PTY session lifecycle
|
|
131
|
-
โ โโโ routes.js # Express HTTP routes
|
|
132
|
-
โ โโโ websocket.js # WebSocket terminal I/O
|
|
133
|
-
โ โโโ tunnel.js # DevTunnel integration
|
|
134
|
-
โ โโโ version.js # Smart version detection
|
|
135
|
-
โโโ public/
|
|
136
|
-
โ โโโ index.html # Session manager UI (mobile)
|
|
137
|
-
โ โโโ terminal.html # Terminal UI (xterm.js)
|
|
138
|
-
โโโ test/ # Unit tests (node:test)
|
|
139
|
-
โโโ docs/ # MkDocs documentation
|
|
140
|
-
โโโ .github/workflows/ # CI, Release, Docs deployment
|
|
75
|
+
```bash
|
|
76
|
+
termbeam [shell] [args...] # start with a specific shell (default: auto-detect)
|
|
77
|
+
termbeam --port 8080 # custom port (default: 3456)
|
|
78
|
+
termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
|
|
141
79
|
```
|
|
142
80
|
|
|
143
|
-
|
|
81
|
+
| Flag | Description | Default |
|
|
82
|
+
| --------------------- | ---------------------------------------- | ----------- |
|
|
83
|
+
| `--password <pw>` | Set access password | None |
|
|
84
|
+
| `--generate-password` | Auto-generate a secure password | โ |
|
|
85
|
+
| `--tunnel` | Create an ephemeral devtunnel URL | Off |
|
|
86
|
+
| `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
|
|
87
|
+
| `--port <port>` | Server port | `3456` |
|
|
88
|
+
| `--host <addr>` | Bind address | `0.0.0.0` |
|
|
144
89
|
|
|
145
|
-
|
|
90
|
+
Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD` (see [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/)).
|
|
146
91
|
|
|
147
|
-
|
|
92
|
+
## Security
|
|
148
93
|
|
|
149
|
-
|
|
150
|
-
- [Configuration](https://dorlugasigal.github.io/TermBeam/configuration/)
|
|
151
|
-
- [Security](https://dorlugasigal.github.io/TermBeam/security/)
|
|
152
|
-
- [API Reference](https://dorlugasigal.github.io/TermBeam/api/)
|
|
153
|
-
- [Architecture](https://dorlugasigal.github.io/TermBeam/architecture/)
|
|
94
|
+
TermBeam binds to all interfaces (`0.0.0.0`) by default, so it's accessible on your local network out of the box. **Always set a password** when running on a shared network, or pass `--host 127.0.0.1` to restrict access to your machine only.
|
|
154
95
|
|
|
155
|
-
|
|
96
|
+
Auth uses secure httpOnly cookies with 24-hour expiry, login is rate-limited to 5 attempts per minute, and security headers (X-Frame-Options, X-Content-Type-Options, etc.) are set on all responses. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header. See the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/) for more.
|
|
156
97
|
|
|
157
|
-
|
|
98
|
+
## Contributing
|
|
158
99
|
|
|
159
|
-
|
|
160
|
-
- Testing guide (Node.js built-in test runner)
|
|
161
|
-
- Commit conventions and PR process
|
|
162
|
-
- Release process (maintainers)
|
|
100
|
+
Contributions welcome โ see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
163
101
|
|
|
164
|
-
##
|
|
102
|
+
## License
|
|
165
103
|
|
|
166
|
-
[MIT](LICENSE)
|
|
104
|
+
[MIT](LICENSE)
|
|
167
105
|
|
|
168
|
-
##
|
|
106
|
+
## Acknowledgments
|
|
169
107
|
|
|
170
108
|
Special thanks to [@tamirdresher](https://github.com/tamirdresher) for the [blog post](https://www.tamirdresher.com/blog/2026/02/26/squad-remote-control) that inspired the solution idea for this project, and for his [cli-tunnel](https://github.com/tamirdresher/cli-tunnel) implementation.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "termbeam",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"description": "Beam your terminal to any device โ mobile-optimized web terminal with multi-session support",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"start": "node bin/termbeam.js",
|
|
11
11
|
"dev": "node bin/termbeam.js --generate-password",
|
|
12
12
|
"test": "node --test test/*.test.js",
|
|
13
|
+
"test:coverage": "c8 --reporter=text --reporter=lcov --reporter=json-summary --reporter=json node --test --test-reporter=spec --test-reporter-destination=stdout test/*.test.js",
|
|
13
14
|
"prepare": "husky",
|
|
14
15
|
"format": "prettier --write .",
|
|
15
16
|
"lint": "node --check src/*.js bin/*.js",
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
"ws": "^8.19.0"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
66
|
+
"c8": "^11.0.0",
|
|
65
67
|
"husky": "^9.1.7",
|
|
66
68
|
"lint-staged": "^16.2.7",
|
|
67
69
|
"prettier": "^3.8.1"
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
|
2
|
+
<rect width="512" height="512" rx="80" fill="#1e1e1e"/>
|
|
3
|
+
<rect x="32" y="32" width="448" height="448" rx="56" fill="#252526" stroke="#3c3c3c" stroke-width="4"/>
|
|
4
|
+
<polyline points="140,200 220,260 140,320" fill="none" stroke="#0078d4" stroke-width="32" stroke-linecap="round" stroke-linejoin="round"/>
|
|
5
|
+
<line x1="260" y1="320" x2="380" y2="320" stroke="#d4d4d4" stroke-width="28" stroke-linecap="round"/>
|
|
6
|
+
</svg>
|
package/public/index.html
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
10
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
11
11
|
<meta name="theme-color" content="#1e1e1e" />
|
|
12
|
+
<link rel="manifest" href="/manifest.json" />
|
|
13
|
+
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
12
14
|
<title>TermBeam</title>
|
|
13
15
|
<style>
|
|
14
16
|
:root {
|
|
@@ -941,6 +943,10 @@
|
|
|
941
943
|
|
|
942
944
|
loadSessions();
|
|
943
945
|
setInterval(loadSessions, 3000);
|
|
946
|
+
|
|
947
|
+
if ('serviceWorker' in navigator) {
|
|
948
|
+
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
949
|
+
}
|
|
944
950
|
</script>
|
|
945
951
|
</body>
|
|
946
952
|
</html>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "TermBeam",
|
|
3
|
+
"short_name": "TermBeam",
|
|
4
|
+
"description": "Beam your terminal to any device",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#1e1e1e",
|
|
8
|
+
"theme_color": "#1e1e1e",
|
|
9
|
+
"icons": [
|
|
10
|
+
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
|
11
|
+
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
|
12
|
+
{ "src": "/icons/icon.svg", "sizes": "any", "type": "image/svg+xml" }
|
|
13
|
+
]
|
|
14
|
+
}
|
package/public/sw.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const CACHE_NAME = 'termbeam-v1';
|
|
2
|
+
const SHELL_URLS = ['/', '/terminal'];
|
|
3
|
+
|
|
4
|
+
self.addEventListener('install', (event) => {
|
|
5
|
+
event.waitUntil(
|
|
6
|
+
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_URLS))
|
|
7
|
+
);
|
|
8
|
+
self.skipWaiting();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
self.addEventListener('activate', (event) => {
|
|
12
|
+
event.waitUntil(
|
|
13
|
+
caches.keys().then((keys) =>
|
|
14
|
+
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
|
15
|
+
)
|
|
16
|
+
);
|
|
17
|
+
self.clients.claim();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
self.addEventListener('fetch', (event) => {
|
|
21
|
+
const url = new URL(event.request.url);
|
|
22
|
+
|
|
23
|
+
// Don't cache WebSocket upgrades or external resources
|
|
24
|
+
if (
|
|
25
|
+
event.request.mode === 'websocket' ||
|
|
26
|
+
url.protocol === 'ws:' ||
|
|
27
|
+
url.protocol === 'wss:' ||
|
|
28
|
+
url.origin !== self.location.origin
|
|
29
|
+
) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Network-first for API calls
|
|
34
|
+
if (url.pathname.startsWith('/api/')) {
|
|
35
|
+
event.respondWith(
|
|
36
|
+
fetch(event.request).catch(() => caches.match(event.request))
|
|
37
|
+
);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Cache-first for static assets
|
|
42
|
+
event.respondWith(
|
|
43
|
+
caches.match(event.request).then((cached) => {
|
|
44
|
+
if (cached) return cached;
|
|
45
|
+
return fetch(event.request).then((response) => {
|
|
46
|
+
if (response.ok) {
|
|
47
|
+
const clone = response.clone();
|
|
48
|
+
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
49
|
+
}
|
|
50
|
+
return response;
|
|
51
|
+
});
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
});
|
package/public/terminal.html
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
10
10
|
<meta name="mobile-web-app-capable" content="yes" />
|
|
11
11
|
<meta name="theme-color" content="#1e1e1e" />
|
|
12
|
+
<link rel="manifest" href="/manifest.json" />
|
|
13
|
+
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
12
14
|
<title>TermBeam โ Terminal</title>
|
|
13
15
|
<link
|
|
14
16
|
rel="stylesheet"
|
|
@@ -283,6 +285,64 @@
|
|
|
283
285
|
overflow-y: hidden !important;
|
|
284
286
|
}
|
|
285
287
|
|
|
288
|
+
#copy-toast {
|
|
289
|
+
position: fixed;
|
|
290
|
+
top: 56px;
|
|
291
|
+
left: 50%;
|
|
292
|
+
transform: translateX(-50%) translateY(-8px);
|
|
293
|
+
background: var(--surface);
|
|
294
|
+
color: var(--text);
|
|
295
|
+
border: 1px solid var(--border);
|
|
296
|
+
padding: 6px 16px;
|
|
297
|
+
border-radius: 8px;
|
|
298
|
+
font-size: 13px;
|
|
299
|
+
font-weight: 600;
|
|
300
|
+
opacity: 0;
|
|
301
|
+
pointer-events: none;
|
|
302
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
303
|
+
z-index: 200;
|
|
304
|
+
}
|
|
305
|
+
#copy-toast.visible {
|
|
306
|
+
opacity: 1;
|
|
307
|
+
transform: translateX(-50%) translateY(0);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#paste-overlay {
|
|
311
|
+
display: none;
|
|
312
|
+
position: fixed;
|
|
313
|
+
top: 0;
|
|
314
|
+
left: 0;
|
|
315
|
+
right: 0;
|
|
316
|
+
bottom: 0;
|
|
317
|
+
background: var(--overlay-bg);
|
|
318
|
+
z-index: 150;
|
|
319
|
+
flex-direction: column;
|
|
320
|
+
align-items: center;
|
|
321
|
+
justify-content: center;
|
|
322
|
+
gap: 12px;
|
|
323
|
+
}
|
|
324
|
+
#paste-overlay.visible {
|
|
325
|
+
display: flex;
|
|
326
|
+
}
|
|
327
|
+
#paste-overlay label {
|
|
328
|
+
font-size: 15px;
|
|
329
|
+
color: #ffffff;
|
|
330
|
+
font-weight: 600;
|
|
331
|
+
}
|
|
332
|
+
#paste-input {
|
|
333
|
+
width: 80%;
|
|
334
|
+
max-width: 400px;
|
|
335
|
+
min-height: 80px;
|
|
336
|
+
background: var(--surface);
|
|
337
|
+
color: var(--text);
|
|
338
|
+
border: 1px solid var(--border);
|
|
339
|
+
border-radius: 8px;
|
|
340
|
+
padding: 10px;
|
|
341
|
+
font-size: 14px;
|
|
342
|
+
font-family: 'NerdFont', 'JetBrains Mono', monospace;
|
|
343
|
+
resize: vertical;
|
|
344
|
+
}
|
|
345
|
+
|
|
286
346
|
#reconnect-overlay {
|
|
287
347
|
display: none;
|
|
288
348
|
position: fixed;
|
|
@@ -357,6 +417,7 @@
|
|
|
357
417
|
</div>
|
|
358
418
|
|
|
359
419
|
<div id="terminal-container"></div>
|
|
420
|
+
<div id="copy-toast">Copied!</div>
|
|
360
421
|
|
|
361
422
|
<div id="key-bar">
|
|
362
423
|
<button class="key-btn" data-key="[A" title="Previous command">โ<span class="hint">prev</span></button>
|
|
@@ -366,6 +427,8 @@
|
|
|
366
427
|
<button class="key-btn" data-key="[H" title="Home">Home</button>
|
|
367
428
|
<button class="key-btn" data-key="[F" title="End">End</button>
|
|
368
429
|
<div class="key-sep"></div>
|
|
430
|
+
<button class="key-btn" id="paste-btn" title="Paste from clipboard">Paste</button>
|
|
431
|
+
<div class="key-sep"></div>
|
|
369
432
|
<button class="key-btn wide" data-key="	" title="Autocomplete">Tab</button>
|
|
370
433
|
<div class="key-sep"></div>
|
|
371
434
|
<button class="key-btn" data-key="" title="Interrupt process">^C<span class="hint">stop</span></button>
|
|
@@ -379,6 +442,15 @@
|
|
|
379
442
|
</div>
|
|
380
443
|
</div>
|
|
381
444
|
|
|
445
|
+
<div id="paste-overlay">
|
|
446
|
+
<label for="paste-input">Paste your text below</label>
|
|
447
|
+
<textarea id="paste-input" placeholder="Long-press here and pasteโฆ"></textarea>
|
|
448
|
+
<div class="overlay-actions">
|
|
449
|
+
<button id="paste-cancel" style="background: rgba(255,255,255,0.15); color: #ffffff;">Cancel</button>
|
|
450
|
+
<button id="paste-send" style="background: var(--accent); color: #ffffff;">Send</button>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
382
454
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
383
455
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
384
456
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
@@ -573,7 +645,7 @@
|
|
|
573
645
|
.addEventListener('touchstart', () => {}, { passive: true });
|
|
574
646
|
document.getElementById('key-bar').addEventListener('click', (e) => {
|
|
575
647
|
const btn = e.target.closest('.key-btn');
|
|
576
|
-
if (!btn) return;
|
|
648
|
+
if (!btn || !btn.dataset.key) return;
|
|
577
649
|
if (ws && ws.readyState === 1) {
|
|
578
650
|
ws.send(JSON.stringify({ type: 'input', data: btn.dataset.key }));
|
|
579
651
|
}
|
|
@@ -592,6 +664,66 @@
|
|
|
592
664
|
document.getElementById('zoom-in').addEventListener('click', () => applyZoom(fontSize + 2));
|
|
593
665
|
document.getElementById('zoom-out').addEventListener('click', () => applyZoom(fontSize - 2));
|
|
594
666
|
|
|
667
|
+
// Clipboard: copy on selection
|
|
668
|
+
function showToast(msg) {
|
|
669
|
+
const toast = document.getElementById('copy-toast');
|
|
670
|
+
toast.textContent = msg;
|
|
671
|
+
toast.classList.add('visible');
|
|
672
|
+
clearTimeout(toast._timer);
|
|
673
|
+
toast._timer = setTimeout(() => toast.classList.remove('visible'), 1500);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
term.onSelectionChange(() => {
|
|
677
|
+
const sel = term.getSelection();
|
|
678
|
+
if (sel && navigator.clipboard && navigator.clipboard.writeText) {
|
|
679
|
+
navigator.clipboard.writeText(sel).then(() => {
|
|
680
|
+
showToast('Copied!');
|
|
681
|
+
}).catch(() => {});
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// Clipboard: paste button
|
|
686
|
+
const pasteOverlay = document.getElementById('paste-overlay');
|
|
687
|
+
const pasteInput = document.getElementById('paste-input');
|
|
688
|
+
|
|
689
|
+
function openPasteModal() {
|
|
690
|
+
pasteInput.value = '';
|
|
691
|
+
pasteOverlay.classList.add('visible');
|
|
692
|
+
pasteInput.focus();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function closePasteModal() {
|
|
696
|
+
pasteOverlay.classList.remove('visible');
|
|
697
|
+
pasteInput.value = '';
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
document.getElementById('paste-btn').addEventListener('mousedown', (e) => {
|
|
701
|
+
e.preventDefault();
|
|
702
|
+
});
|
|
703
|
+
document.getElementById('paste-btn').addEventListener('click', () => {
|
|
704
|
+
if (navigator.clipboard && navigator.clipboard.readText) {
|
|
705
|
+
navigator.clipboard.readText().then((text) => {
|
|
706
|
+
if (text && ws && ws.readyState === 1) {
|
|
707
|
+
ws.send(JSON.stringify({ type: 'input', data: text }));
|
|
708
|
+
showToast('Pasted!');
|
|
709
|
+
}
|
|
710
|
+
}).catch(() => {
|
|
711
|
+
openPasteModal();
|
|
712
|
+
});
|
|
713
|
+
} else {
|
|
714
|
+
openPasteModal();
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
document.getElementById('paste-send').addEventListener('click', () => {
|
|
719
|
+
const text = pasteInput.value;
|
|
720
|
+
if (text && ws && ws.readyState === 1) {
|
|
721
|
+
ws.send(JSON.stringify({ type: 'input', data: text }));
|
|
722
|
+
}
|
|
723
|
+
closePasteModal();
|
|
724
|
+
});
|
|
725
|
+
document.getElementById('paste-cancel').addEventListener('click', closePasteModal);
|
|
726
|
+
|
|
595
727
|
function scheduleReconnect() {
|
|
596
728
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
597
729
|
const seconds = Math.round(reconnectDelay / 1000);
|
|
@@ -671,6 +803,10 @@
|
|
|
671
803
|
|
|
672
804
|
connect();
|
|
673
805
|
}
|
|
806
|
+
|
|
807
|
+
if ('serviceWorker' in navigator) {
|
|
808
|
+
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
809
|
+
}
|
|
674
810
|
</script>
|
|
675
811
|
</body>
|
|
676
812
|
</html>
|
package/src/cli.js
CHANGED
|
@@ -12,7 +12,8 @@ Usage:
|
|
|
12
12
|
Options:
|
|
13
13
|
--password <pw> Set access password (or TERMBEAM_PASSWORD env var)
|
|
14
14
|
--generate-password Auto-generate a secure password
|
|
15
|
-
--tunnel Create a public devtunnel URL
|
|
15
|
+
--tunnel Create a public devtunnel URL (ephemeral)
|
|
16
|
+
--persisted-tunnel Create a reusable devtunnel URL (stable across restarts)
|
|
16
17
|
--port <port> Set port (default: 3456, or PORT env var)
|
|
17
18
|
--host <addr> Bind address (default: 0.0.0.0)
|
|
18
19
|
-h, --help Show this help
|
|
@@ -90,6 +91,7 @@ function parseArgs() {
|
|
|
90
91
|
const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
|
|
91
92
|
let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
|
|
92
93
|
let useTunnel = false;
|
|
94
|
+
let persistedTunnel = false;
|
|
93
95
|
|
|
94
96
|
const args = process.argv.slice(2);
|
|
95
97
|
const filteredArgs = [];
|
|
@@ -99,6 +101,9 @@ function parseArgs() {
|
|
|
99
101
|
password = args[++i];
|
|
100
102
|
} else if (args[i] === '--tunnel') {
|
|
101
103
|
useTunnel = true;
|
|
104
|
+
} else if (args[i] === '--persisted-tunnel') {
|
|
105
|
+
useTunnel = true;
|
|
106
|
+
persistedTunnel = true;
|
|
102
107
|
} else if (args[i].startsWith('--password=')) {
|
|
103
108
|
password = args[i].split('=')[1];
|
|
104
109
|
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
@@ -126,7 +131,7 @@ function parseArgs() {
|
|
|
126
131
|
const { getVersion } = require('./version');
|
|
127
132
|
const version = getVersion();
|
|
128
133
|
|
|
129
|
-
return { port, host, password, useTunnel, shell, shellArgs, cwd, defaultShell, version };
|
|
134
|
+
return { port, host, password, useTunnel, persistedTunnel, shell, shellArgs, cwd, defaultShell, version };
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
module.exports = { parseArgs, printHelp };
|
package/src/routes.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
const os = require('os');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const express = require('express');
|
|
4
5
|
const { detectShells } = require('./shells');
|
|
5
6
|
|
|
6
7
|
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
|
7
8
|
|
|
8
9
|
function setupRoutes(app, { auth, sessions, config }) {
|
|
10
|
+
// Serve static files (manifest.json, sw.js, icons, etc.)
|
|
11
|
+
app.use(express.static(PUBLIC_DIR, { index: false }));
|
|
12
|
+
|
|
9
13
|
// Login page
|
|
10
14
|
app.get('/login', (_req, res) => {
|
|
11
15
|
if (!auth.password) return res.redirect('/');
|
package/src/server.js
CHANGED
|
@@ -38,11 +38,17 @@ setupRoutes(app, { auth, sessions, config });
|
|
|
38
38
|
setupWebSocket(wss, { auth, sessions });
|
|
39
39
|
|
|
40
40
|
// --- Lifecycle ---
|
|
41
|
+
let shuttingDown = false;
|
|
41
42
|
function shutdown() {
|
|
43
|
+
if (shuttingDown) return;
|
|
44
|
+
shuttingDown = true;
|
|
42
45
|
console.log('\n[termbeam] Shutting down...');
|
|
43
46
|
sessions.shutdown();
|
|
44
47
|
cleanupTunnel();
|
|
45
|
-
|
|
48
|
+
server.close();
|
|
49
|
+
wss.close();
|
|
50
|
+
// Force exit after giving connections time to close
|
|
51
|
+
setTimeout(() => process.exit(0), 500).unref();
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
process.on('SIGINT', shutdown);
|
|
@@ -52,7 +58,6 @@ process.on('uncaughtException', (err) => {
|
|
|
52
58
|
cleanupTunnel();
|
|
53
59
|
process.exit(1);
|
|
54
60
|
});
|
|
55
|
-
process.on('exit', cleanupTunnel);
|
|
56
61
|
|
|
57
62
|
// --- Start ---
|
|
58
63
|
function getLocalIP() {
|
|
@@ -112,10 +117,12 @@ server.listen(config.port, config.host, async () => {
|
|
|
112
117
|
|
|
113
118
|
let publicUrl = null;
|
|
114
119
|
if (config.useTunnel) {
|
|
115
|
-
|
|
116
|
-
if (
|
|
120
|
+
const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
|
|
121
|
+
if (tunnel) {
|
|
122
|
+
publicUrl = tunnel.url;
|
|
117
123
|
console.log('');
|
|
118
124
|
console.log(` ๐ Public: ${publicUrl}`);
|
|
125
|
+
console.log(` Tunnel: ${tunnel.mode} (expires in ${tunnel.expiry})`);
|
|
119
126
|
} else {
|
|
120
127
|
console.log('');
|
|
121
128
|
console.log(' โ ๏ธ Tunnel failed to start. Using LAN only.');
|
package/src/tunnel.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
const { execSync, spawn } = require('child_process');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
const TUNNEL_CONFIG_DIR = path.join(os.homedir(), '.termbeam');
|
|
7
|
+
const TUNNEL_CONFIG_PATH = path.join(TUNNEL_CONFIG_DIR, 'tunnel.json');
|
|
4
8
|
|
|
5
9
|
let tunnelId = null;
|
|
6
10
|
let tunnelProc = null;
|
|
@@ -29,7 +33,45 @@ function findDevtunnel() {
|
|
|
29
33
|
return null;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
function loadPersistedTunnel() {
|
|
37
|
+
try {
|
|
38
|
+
if (fs.existsSync(TUNNEL_CONFIG_PATH)) {
|
|
39
|
+
return JSON.parse(fs.readFileSync(TUNNEL_CONFIG_PATH, 'utf-8'));
|
|
40
|
+
}
|
|
41
|
+
} catch {}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function savePersistedTunnel(id) {
|
|
46
|
+
fs.mkdirSync(TUNNEL_CONFIG_DIR, { recursive: true });
|
|
47
|
+
fs.writeFileSync(TUNNEL_CONFIG_PATH, JSON.stringify({ tunnelId: id, createdAt: new Date().toISOString() }, null, 2));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function deletePersisted() {
|
|
51
|
+
const persisted = loadPersistedTunnel();
|
|
52
|
+
if (persisted) {
|
|
53
|
+
try {
|
|
54
|
+
execSync(`"${devtunnelCmd}" delete ${persisted.tunnelId} -f`, { stdio: 'pipe' });
|
|
55
|
+
console.log(`[termbeam] Deleted persisted tunnel ${persisted.tunnelId}`);
|
|
56
|
+
} catch {}
|
|
57
|
+
try {
|
|
58
|
+
fs.unlinkSync(TUNNEL_CONFIG_PATH);
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isTunnelValid(id) {
|
|
64
|
+
try {
|
|
65
|
+
execSync(`"${devtunnelCmd}" show ${id} --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let isPersisted = false;
|
|
73
|
+
|
|
74
|
+
async function startTunnel(port, options = {}) {
|
|
33
75
|
// Check if devtunnel CLI is installed
|
|
34
76
|
const found = findDevtunnel();
|
|
35
77
|
if (!found) {
|
|
@@ -66,16 +108,48 @@ async function startTunnel(port) {
|
|
|
66
108
|
execSync(`"${devtunnelCmd}" user login`, { stdio: 'inherit' });
|
|
67
109
|
}
|
|
68
110
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
tunnelId = tunnelData.tunnel.tunnelId;
|
|
111
|
+
const persisted = options.persisted;
|
|
112
|
+
isPersisted = !!persisted;
|
|
72
113
|
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
// Try to reuse persisted tunnel
|
|
115
|
+
let tunnelMode, tunnelExpiry;
|
|
116
|
+
if (persisted) {
|
|
117
|
+
tunnelMode = 'persisted';
|
|
118
|
+
tunnelExpiry = '30 days';
|
|
119
|
+
const saved = loadPersistedTunnel();
|
|
120
|
+
if (saved && isTunnelValid(saved.tunnelId)) {
|
|
121
|
+
tunnelId = saved.tunnelId;
|
|
122
|
+
console.log(`[termbeam] Reusing persisted tunnel ${tunnelId}`);
|
|
123
|
+
} else {
|
|
124
|
+
if (saved) {
|
|
125
|
+
console.log('[termbeam] Persisted tunnel expired, creating new one');
|
|
126
|
+
}
|
|
127
|
+
const createOut = execSync(`"${devtunnelCmd}" create --expiration 30d --json`, { encoding: 'utf-8' });
|
|
128
|
+
const tunnelData = JSON.parse(createOut);
|
|
129
|
+
tunnelId = tunnelData.tunnel.tunnelId;
|
|
130
|
+
savePersistedTunnel(tunnelId);
|
|
131
|
+
console.log(`[termbeam] Created new persisted tunnel ${tunnelId}`);
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
tunnelMode = 'ephemeral';
|
|
135
|
+
tunnelExpiry = '1 day';
|
|
136
|
+
// Ephemeral tunnel โ create fresh, will be deleted on shutdown
|
|
137
|
+
const createOut = execSync(`"${devtunnelCmd}" create --expiration 1d --json`, { encoding: 'utf-8' });
|
|
138
|
+
const tunnelData = JSON.parse(createOut);
|
|
139
|
+
tunnelId = tunnelData.tunnel.tunnelId;
|
|
140
|
+
console.log(`[termbeam] Created ephemeral tunnel ${tunnelId}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Idempotent port and access setup
|
|
144
|
+
try {
|
|
145
|
+
execSync(`"${devtunnelCmd}" port create ${tunnelId} -p ${port} --protocol http`, { stdio: 'pipe' });
|
|
146
|
+
} catch {}
|
|
147
|
+
try {
|
|
148
|
+
execSync(`"${devtunnelCmd}" access create ${tunnelId} -p ${port} --anonymous`, { stdio: 'pipe' });
|
|
149
|
+
} catch {}
|
|
75
150
|
|
|
76
151
|
const hostProc = spawn(devtunnelCmd, ['host', tunnelId], {
|
|
77
152
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
78
|
-
detached: true,
|
|
79
153
|
});
|
|
80
154
|
tunnelProc = hostProc;
|
|
81
155
|
|
|
@@ -88,7 +162,7 @@ async function startTunnel(port) {
|
|
|
88
162
|
const match = output.match(/(https:\/\/[^\s]+devtunnels\.ms[^\s]*)/);
|
|
89
163
|
if (match) {
|
|
90
164
|
clearTimeout(timeout);
|
|
91
|
-
resolve(match[1]);
|
|
165
|
+
resolve({ url: match[1], mode: tunnelMode, expiry: tunnelExpiry });
|
|
92
166
|
}
|
|
93
167
|
});
|
|
94
168
|
hostProc.stderr.on('data', (data) => {
|
|
@@ -106,17 +180,35 @@ async function startTunnel(port) {
|
|
|
106
180
|
}
|
|
107
181
|
|
|
108
182
|
function cleanupTunnel() {
|
|
109
|
-
|
|
183
|
+
const id = tunnelId;
|
|
184
|
+
if (tunnelProc) {
|
|
110
185
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
186
|
+
// On Windows, kill the process tree to ensure all children die
|
|
187
|
+
if (process.platform === 'win32' && tunnelProc.pid) {
|
|
188
|
+
try {
|
|
189
|
+
execSync(`taskkill /pid ${tunnelProc.pid} /T /F`, { stdio: 'pipe', timeout: 5000 });
|
|
190
|
+
} catch { /* best effort */ }
|
|
191
|
+
} else {
|
|
192
|
+
tunnelProc.kill('SIGKILL');
|
|
193
|
+
}
|
|
114
194
|
} catch {
|
|
115
195
|
/* best effort */
|
|
116
196
|
}
|
|
117
|
-
tunnelId = null;
|
|
118
197
|
tunnelProc = null;
|
|
119
198
|
}
|
|
199
|
+
if (id) {
|
|
200
|
+
tunnelId = null;
|
|
201
|
+
if (isPersisted) {
|
|
202
|
+
console.log('[termbeam] Tunnel host stopped (tunnel preserved for reuse)');
|
|
203
|
+
} else {
|
|
204
|
+
try {
|
|
205
|
+
execSync(`"${devtunnelCmd}" delete ${id} -f`, { stdio: 'pipe', timeout: 10000 });
|
|
206
|
+
console.log('[termbeam] Tunnel cleaned up');
|
|
207
|
+
} catch {
|
|
208
|
+
/* best effort โ tunnel will expire on its own */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
120
212
|
}
|
|
121
213
|
|
|
122
214
|
module.exports = { startTunnel, cleanupTunnel };
|