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 CHANGED
@@ -1,40 +1,21 @@
1
- <div align="center">
1
+ # TermBeam
2
2
 
3
- # ๐Ÿ“ก TermBeam
4
-
5
- **Beam your terminal to any device**
3
+ **Beam your terminal to any device.**
6
4
 
7
5
  [![npm version](https://img.shields.io/npm/v/termbeam.svg)](https://www.npmjs.com/package/termbeam)
8
6
  [![CI](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml/badge.svg)](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
7
+ [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/dorlugasigal/TermBeam/coverage-data/endpoint.json)](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
9
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
10
- [![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](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
- [Getting Started](#-quick-start) ยท [Demo](#-demo) ยท [Documentation](https://dorlugasigal.github.io/TermBeam/) ยท [Contributing](CONTRIBUTING.md)
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
- </div>
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
- ## โœจ Features
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
- That's it. Scan the QR code printed in your terminal, or open the URL on any device.
31
+ Scan the QR code printed in your terminal, or open the URL on any device.
51
32
 
52
- ### With password protection (recommended)
33
+ ### Password protection (recommended)
53
34
 
54
35
  ```bash
55
- # Auto-generate a secure password
56
36
  termbeam --generate-password
57
37
 
58
- # Or set your own
38
+ # or set your own
59
39
  termbeam --password mysecret
60
40
  ```
61
41
 
62
- ### Remote access from anywhere
63
-
64
- ```bash
65
- termbeam --tunnel --generate-password
66
- ```
42
+ ## Features
67
43
 
68
- > Requires the [Azure Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started):
69
- >
70
- > - **Windows:** `winget install Microsoft.devtunnel`
71
- > - **macOS:** `brew install --cask devtunnel`
72
- > - **Linux:** `curl -sL https://aka.ms/DevTunnelCliInstall | bash`
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
- ## ๐Ÿ“– Usage
55
+ ## Remote Access
75
56
 
76
57
  ```bash
77
- # Start with your default shell
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
- ### Environment Variables
103
-
104
- | Variable | Description |
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
- TermBeam is designed for **local network use**. Key security features:
65
+ Requires the [Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started):
113
66
 
114
- - ๐Ÿ”‘ **Token-based auth** with secure, httpOnly cookies (24-hour expiry)
115
- - ๐Ÿ›ก๏ธ **Rate limiting** on login (5 attempts per minute)
116
- - ๐Ÿ”’ **Security headers** (X-Frame-Options, X-Content-Type-Options, CSP, etc.)
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
- > โš ๏ธ **Always use a password when exposing to any network.** See the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/) for production deployment tips.
71
+ Persisted tunnels save a tunnel ID to `~/.termbeam/tunnel.json` so the URL stays the same between sessions.
120
72
 
121
- ## ๐Ÿ—๏ธ Architecture
73
+ ## CLI Reference
122
74
 
123
- ```
124
- termbeam/
125
- โ”œโ”€โ”€ bin/termbeam.js # CLI entry point
126
- โ”œโ”€โ”€ src/
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
- See the [Architecture Guide](https://dorlugasigal.github.io/TermBeam/architecture/) for details.
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
- ## ๐Ÿ“š Documentation
90
+ Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD` (see [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/)).
146
91
 
147
- Full documentation is available at **[dorlugasigal.github.io/TermBeam](https://dorlugasigal.github.io/TermBeam/)**
92
+ ## Security
148
93
 
149
- - [Getting Started](https://dorlugasigal.github.io/TermBeam/getting-started/)
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
- ## ๐Ÿค Contributing
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
- Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for:
98
+ ## Contributing
158
99
 
159
- - Development setup and local workflow
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
- ## ๐Ÿ“„ License
102
+ ## License
165
103
 
166
- [MIT](LICENSE) โ€” made with โค๏ธ by [@dorlugasigal](https://github.com/dorlugasigal)
104
+ [MIT](LICENSE)
167
105
 
168
- ## ๐Ÿ™ Acknowledgments
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.6",
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
+ });
@@ -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="&#x1b;[A" title="Previous command">โ†‘<span class="hint">prev</span></button>
@@ -366,6 +427,8 @@
366
427
  <button class="key-btn" data-key="&#x1b;[H" title="Home">Home</button>
367
428
  <button class="key-btn" data-key="&#x1b;[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="&#x09;" title="Autocomplete">Tab</button>
370
433
  <div class="key-sep"></div>
371
434
  <button class="key-btn" data-key="&#x03;" 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
- process.exit(0);
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
- publicUrl = await startTunnel(config.port);
116
- if (publicUrl) {
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
- async function startTunnel(port) {
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 createOut = execSync(`"${devtunnelCmd}" create --expiration 1d --json`, { encoding: 'utf-8' });
70
- const tunnelData = JSON.parse(createOut);
71
- tunnelId = tunnelData.tunnel.tunnelId;
111
+ const persisted = options.persisted;
112
+ isPersisted = !!persisted;
72
113
 
73
- execSync(`"${devtunnelCmd}" port create ${tunnelId} -p ${port} --protocol http`, { stdio: 'pipe' });
74
- execSync(`"${devtunnelCmd}" access create ${tunnelId} -p ${port} --anonymous`, { stdio: 'pipe' });
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
- if (tunnelId) {
183
+ const id = tunnelId;
184
+ if (tunnelProc) {
110
185
  try {
111
- if (tunnelProc) tunnelProc.kill();
112
- execSync(`"${devtunnelCmd}" delete ${tunnelId} -f`, { stdio: 'pipe' });
113
- console.log('[termbeam] Tunnel cleaned up');
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 };