termbeam 1.8.0 → 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 CHANGED
@@ -8,14 +8,13 @@
8
8
  [![npm downloads](https://img.shields.io/npm/dm/termbeam.svg)](https://www.npmjs.com/package/termbeam)
9
9
  [![CI](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml/badge.svg)](https://github.com/dorlugasigal/TermBeam/actions/workflows/ci.yml)
10
10
  [![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)
11
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/dorlugasigal/TermBeam/badge)](https://securityscorecards.dev/viewer/?uri=github.com/dorlugasigal/TermBeam)
11
12
  [![Node.js](https://img.shields.io/node/v/termbeam.svg)](https://nodejs.org/)
12
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
13
14
 
14
15
  </div>
15
16
 
16
- 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.
17
-
18
- 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.
19
18
 
20
19
  [Full documentation](https://dorlugasigal.github.io/TermBeam/) · [Website](https://termbeam.pages.dev)
21
20
 
@@ -46,90 +45,79 @@ termbeam
46
45
 
47
46
  Scan the QR code printed in your terminal, or open the URL on any device.
48
47
 
49
- > **First time?** Run `termbeam -i` for a guided setup wizard that walks you through password, port, and access mode.
50
-
51
- ### Secure by default
52
-
53
- TermBeam starts with a tunnel and auto-generated password out of the box — just run `termbeam` and scan the QR code.
54
-
55
48
  ```bash
56
49
  termbeam # tunnel + auto-password (default)
57
- termbeam --password mysecret # use a specific password
58
- termbeam --no-tunnel # LAN-only (no tunnel)
59
- termbeam --no-password # disable password protection
50
+ termbeam --password mysecret # custom password
51
+ termbeam --no-tunnel # LAN only
60
52
  termbeam -i # interactive setup wizard
61
53
  ```
62
54
 
63
- ## Remote Access
55
+ ## Features
64
56
 
65
- ```bash
66
- # Tunnel is on by default
67
- termbeam
57
+ ### Mobile-First
68
58
 
69
- # Persisted tunnel (stable URL you can bookmark, reused across restarts, 30-day expiry)
70
- termbeam --persisted-tunnel
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
71
63
 
72
- # LAN-only (no tunnel)
73
- termbeam --no-tunnel
74
- ```
64
+ ### Multi-Session
75
65
 
76
- If the [Dev Tunnels CLI](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started) is not installed, TermBeam will offer to install it for you automatically. You can also install it manually:
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
77
70
 
78
- - **Windows:** `winget install Microsoft.devtunnel`
79
- - **macOS:** `brew install --cask devtunnel`
80
- - **Linux:** `curl -sL https://aka.ms/DevTunnelCliInstall | bash`
71
+ ### Productivity
81
72
 
82
- Persisted tunnels save a tunnel ID to `~/.termbeam/tunnel.json` so the URL stays the same between sessions.
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
83
79
 
84
- ## CLI Reference
80
+ ### Secure by Default
85
81
 
86
- ```bash
87
- termbeam [shell] [args...] # start with a specific shell (default: auto-detect)
88
- termbeam --port 8080 # custom port (default: 3456)
89
- termbeam --host 0.0.0.0 # allow LAN access (default: 127.0.0.1)
90
- termbeam --lan # shortcut for --host 0.0.0.0
91
- termbeam -i # interactive setup wizard
92
- termbeam service install # interactive PM2 service setup wizard
93
- termbeam service uninstall # stop & remove PM2 service
94
- termbeam service status # show PM2 service status
95
- termbeam service logs # tail PM2 service logs
96
- termbeam service restart # restart PM2 service
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"]
97
97
  ```
98
98
 
99
- | Flag | Description | Default |
100
- | --------------------- | ---------------------------------------------------- | -------------- |
101
- | `--password <pw>` | Set access password (also accepts `--password=<pw>`) | Auto-generated |
102
- | `--no-password` | Disable password (cannot combine with `--public`) | — |
103
- | `--generate-password` | Auto-generate a secure password | On |
104
- | `--tunnel` | Create an ephemeral devtunnel URL (private) | On |
105
- | `--no-tunnel` | Disable tunnel (LAN-only) | — |
106
- | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
107
- | `--public` | Allow public tunnel access | 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
- | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
112
- | `-i, --interactive` | Interactive setup wizard (guided configuration) | Off |
113
- | `-h, --help` | Show help | — |
114
- | `-v, --version` | Show version | — |
115
-
116
- | Subcommand | Description |
117
- | ------------------- | ----------------------------- |
118
- | `service install` | Interactive PM2 service setup |
119
- | `service uninstall` | Stop & remove from PM2 |
120
- | `service status` | Show PM2 service status |
121
- | `service logs` | Tail PM2 service logs |
122
- | `service restart` | Restart PM2 service |
123
-
124
- 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
125
100
 
126
- ## Security
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/).
127
115
 
128
- TermBeam auto-generates a password and creates a tunnel by default, so your terminal is protected out of the box. By default, the server binds to `127.0.0.1` (localhost only). Use `--lan` or `--host 0.0.0.0` to allow LAN access, or `--no-tunnel` to disable the tunnel.
116
+ ## Security
129
117
 
130
- 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. Each QR code contains a single-use share token (5-minute expiry) for password-free login. API clients that can't use cookies can authenticate with an `Authorization: Bearer <password>` header.
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.
131
119
 
132
- For the full threat model, safe usage guidance, and a quick safety checklist, see [SECURITY.md](SECURITY.md). For detailed security feature documentation, see the [Security Guide](https://dorlugasigal.github.io/TermBeam/security/).
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/).
133
121
 
134
122
  ## Contributing
135
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
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": {
@@ -68,13 +68,17 @@
68
68
  "dependencies": {
69
69
  "cookie-parser": "^1.4.7",
70
70
  "express": "^5.2.1",
71
+ "express-rate-limit": "^8.2.1",
71
72
  "node-pty": "^1.1.0",
72
73
  "qrcode": "^1.5.4",
73
74
  "ws": "^8.19.0"
74
75
  },
75
76
  "devDependencies": {
77
+ "@eslint/js": "^9.39.3",
76
78
  "@playwright/test": "^1.58.2",
77
79
  "c8": "^11.0.0",
80
+ "eslint": "^10.0.2",
81
+ "eslint-plugin-security": "^4.0.0",
78
82
  "husky": "^9.1.7",
79
83
  "lint-staged": "^16.2.7",
80
84
  "prettier": "^3.8.1"
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 };
@@ -85,7 +85,7 @@ async function runInteractiveSetup(baseConfig) {
85
85
  let passwordMode = 'auto';
86
86
  if (pwChoice.index === 0) {
87
87
  config.password = crypto.randomBytes(16).toString('base64url');
88
- console.log(dim(` Generated password: ${config.password}`));
88
+ process.stdout.write(dim(` Generated password: ${config.password}`) + '\n');
89
89
  } else if (pwChoice.index === 1) {
90
90
  passwordMode = 'custom';
91
91
  config.password = await ask(rl, 'Enter password:');
@@ -99,7 +99,7 @@ async function runInteractiveSetup(baseConfig) {
99
99
  }
100
100
  decisions.push({
101
101
  label: 'Password',
102
- value: config.password == null ? yellow('disabled') : '••••••••',
102
+ value: config.password === null ? yellow('disabled') : '••••••••',
103
103
  });
104
104
 
105
105
  // Step 2: Port
@@ -164,7 +164,7 @@ async function runInteractiveSetup(baseConfig) {
164
164
  if (config.publicTunnel && !config.password) {
165
165
  console.log(yellow(' ⚠ Public tunnels require password authentication.'));
166
166
  config.password = crypto.randomBytes(16).toString('base64url');
167
- console.log(dim(` Auto-generated password: ${config.password}`));
167
+ process.stdout.write(dim(` Auto-generated password: ${config.password}`) + '\n');
168
168
  passwordMode = 'auto';
169
169
  // Update the password decision
170
170
  decisions[0] = { label: 'Password', value: '••••••••' };
@@ -212,7 +212,7 @@ async function runInteractiveSetup(baseConfig) {
212
212
  showProgress(4);
213
213
  console.log(bold('\n── Configuration Summary ──────────────────'));
214
214
  console.log(
215
- ` Password: ${config.password == null ? yellow('disabled') : cyan('••••••••')}`,
215
+ ` Password: ${config.password === null ? yellow('disabled') : cyan('••••••••')}`,
216
216
  );
217
217
  console.log(` Port: ${cyan(String(config.port))}`);
218
218
  console.log(
package/src/prompts.js CHANGED
@@ -19,11 +19,11 @@ const dim = (t) => color('2', t);
19
19
  * If `defaultValue` is provided, it's shown in brackets and used when the user presses Enter.
20
20
  */
21
21
  function ask(rl, question, defaultValue) {
22
- const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' ';
22
+ const suffix = defaultValue != null ? ` ${dim(`[${defaultValue}]`)} ` : ' '; // eslint-disable-line eqeqeq
23
23
  return new Promise((resolve) => {
24
24
  rl.question(`${question}${suffix}`, (answer) => {
25
25
  const trimmed = answer.trim();
26
- resolve(trimmed || (defaultValue != null ? String(defaultValue) : ''));
26
+ resolve(trimmed || (defaultValue != null ? String(defaultValue) : '')); // eslint-disable-line eqeqeq
27
27
  });
28
28
  });
29
29
  }