termbeam 0.1.1 → 1.0.1

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
@@ -105,8 +105,9 @@ termbeam --host 127.0.0.1 # restrict to localhost (default: 0.0.0.0)
105
105
  | `--persisted-tunnel` | Create a reusable devtunnel URL | Off |
106
106
  | `--port <port>` | Server port | `3456` |
107
107
  | `--host <addr>` | Bind address | `0.0.0.0` |
108
+ | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
108
109
 
109
- Environment variables: `PORT`, `TERMBEAM_PASSWORD`, `TERMBEAM_CWD`, `SHELL` (Unix fallback), `COMSPEC` (Windows fallback). See [Configuration docs](https://dorlugasigal.github.io/TermBeam/configuration/).
110
+ 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/).
110
111
 
111
112
  ## Security
112
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "0.1.1",
3
+ "version": "1.0.1",
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": {
@@ -9,8 +9,8 @@
9
9
  "scripts": {
10
10
  "start": "node bin/termbeam.js",
11
11
  "dev": "node bin/termbeam.js --generate-password",
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",
12
+ "test": "node -e \"require('child_process').execFileSync(process.execPath,['--test',...require('fs').readdirSync('test').filter(f=>f.endsWith('.test.js')).map(f=>'test/'+f)],{stdio:'inherit'})\"",
13
+ "test:coverage": "c8 --exclude=src/tunnel.js --reporter=text --reporter=lcov --reporter=json-summary --reporter=json node -e \"require('child_process').execFileSync(process.execPath,['--test','--test-reporter=spec','--test-reporter-destination=stdout',...require('fs').readdirSync('test').filter(f=>f.endsWith('.test.js')).map(f=>'test/'+f)],{stdio:'inherit'})\"",
14
14
  "prepare": "husky",
15
15
  "format": "prettier --write .",
16
16
  "lint": "node --check src/*.js bin/*.js",
@@ -24,9 +24,18 @@
24
24
  "remote-terminal",
25
25
  "xterm",
26
26
  "websocket",
27
- "ssh-alternative"
27
+ "ssh-alternative",
28
+ "mobile-terminal",
29
+ "terminal-sharing",
30
+ "browser-terminal",
31
+ "remote-access",
32
+ "qr-code",
33
+ "touch-terminal",
34
+ "terminal-emulator",
35
+ "devtools",
36
+ "cli"
28
37
  ],
29
- "author": "",
38
+ "author": "Dor Lugasi <dorlugasigal@gmail.com>",
30
39
  "license": "MIT",
31
40
  "homepage": "https://github.com/dorlugasigal/TermBeam",
32
41
  "repository": {
package/public/index.html CHANGED
@@ -9,9 +9,10 @@
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
+ <meta name="description" content="TermBeam — beam your terminal to any device. Mobile-optimized web terminal with multi-session support, touch controls, and QR code connection. No SSH needed." />
12
13
  <link rel="manifest" href="/manifest.json" />
13
14
  <link rel="apple-touch-icon" href="/icons/icon-192.png" />
14
- <title>TermBeam</title>
15
+ <title>TermBeam — Beam Your Terminal to Any Device</title>
15
16
  <style>
16
17
  :root {
17
18
  --bg: #1e1e1e;
@@ -958,7 +959,13 @@
958
959
  let currentBrowsePath = '/';
959
960
  let hubServerCwd = '/';
960
961
 
961
- document.getElementById('browse-btn').addEventListener('click', () => {
962
+ document.getElementById('browse-btn').addEventListener('click', async () => {
963
+ if (hubServerCwd === '/') {
964
+ try {
965
+ const data = await fetch('/api/shells').then(r => r.json());
966
+ if (data.cwd) hubServerCwd = data.cwd;
967
+ } catch {}
968
+ }
962
969
  const initial = cwdInput.value.trim() || hubServerCwd;
963
970
  navigateTo(initial);
964
971
  browserOverlay.classList.add('visible');
@@ -6,6 +6,7 @@
6
6
  <meta name="apple-mobile-web-app-capable" content="yes" />
7
7
  <meta name="mobile-web-app-capable" content="yes" />
8
8
  <meta name="theme-color" content="#1e1e1e" />
9
+ <meta name="description" content="TermBeam terminal session — access your terminal remotely from any browser with a mobile-optimized touch interface." />
9
10
  <link rel="manifest" href="/manifest.json" />
10
11
  <link rel="apple-touch-icon" href="/icons/icon-192.png" />
11
12
  <title>TermBeam — Terminal</title>
@@ -1349,12 +1350,16 @@
1349
1350
  }
1350
1351
 
1351
1352
  async function removeSession(id) {
1352
- try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
1353
-
1354
1353
  const ms = managed.get(id);
1355
1354
  if (ms) {
1355
+ ms.exited = true;
1356
+ if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
1356
1357
  if (ms.ws) try { ms.ws.close(); } catch {}
1357
- if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
1358
+ }
1359
+
1360
+ try { await fetch('/api/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); } catch {}
1361
+
1362
+ if (ms) {
1358
1363
  ms.term.dispose();
1359
1364
  ms.container.remove();
1360
1365
  managed.delete(id);
@@ -2007,7 +2012,13 @@
2007
2012
  let nsBrowsePath = '/';
2008
2013
  let serverCwd = '/';
2009
2014
 
2010
- document.getElementById('ns-browse-btn').addEventListener('click', () => {
2015
+ document.getElementById('ns-browse-btn').addEventListener('click', async () => {
2016
+ if (serverCwd === '/') {
2017
+ try {
2018
+ const data = await fetch('/api/shells').then(r => r.json());
2019
+ if (data.cwd) serverCwd = data.cwd;
2020
+ } catch {}
2021
+ }
2011
2022
  const initial = nsCwdInput.value.trim() || serverCwd;
2012
2023
  nsBrowseNavigate(initial);
2013
2024
  nsBrowserOverlay.classList.add('visible');
@@ -2162,8 +2173,9 @@
2162
2173
  for (const id of [...managed.keys()]) {
2163
2174
  if (!serverIds.has(id)) {
2164
2175
  const ms = managed.get(id);
2176
+ ms.exited = true;
2177
+ if (ms.reconnectTimer) { clearTimeout(ms.reconnectTimer); ms.reconnectTimer = null; }
2165
2178
  if (ms.ws) try { ms.ws.close(); } catch {}
2166
- if (ms.reconnectTimer) clearTimeout(ms.reconnectTimer);
2167
2179
  ms.term.dispose();
2168
2180
  ms.container.remove();
2169
2181
  managed.delete(id);
package/src/auth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const crypto = require('crypto');
2
+ const log = require('./logger');
2
3
 
3
4
  const LOGIN_HTML = `<!DOCTYPE html>
4
5
  <html lang="en">
@@ -107,7 +108,7 @@ function createAuth(password) {
107
108
  const attempts = authAttempts.get(ip) || [];
108
109
  const recent = attempts.filter((t) => now - t < window);
109
110
  if (recent.length >= maxAttempts) {
110
- console.warn(`[termbeam] Auth: rate limit exceeded for ${ip}`);
111
+ log.warn(`Auth: rate limit exceeded for ${ip}`);
111
112
  return res.status(429).json({ error: 'Too many attempts. Try again later.' });
112
113
  }
113
114
  recent.push(now);
package/src/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const os = require('os');
2
2
  const path = require('path');
3
3
  const crypto = require('crypto');
4
+ const log = require('./logger');
4
5
 
5
6
  function printHelp() {
6
7
  console.log(`
@@ -18,6 +19,7 @@ Options:
18
19
  --persisted-tunnel Create a reusable devtunnel URL (stable across restarts)
19
20
  --port <port> Set port (default: 3456, or PORT env var)
20
21
  --host <addr> Bind address (default: 0.0.0.0)
22
+ --log-level <level> Set log verbosity: error, warn, info, debug (default: info)
21
23
  -h, --help Show this help
22
24
  -v, --version Show version
23
25
 
@@ -37,6 +39,7 @@ Environment:
37
39
  PORT Server port (default: 3456)
38
40
  TERMBEAM_PASSWORD Access password
39
41
  TERMBEAM_CWD Working directory
42
+ TERMBEAM_LOG_LEVEL Log level (default: info)
40
43
  `);
41
44
  }
42
45
 
@@ -82,13 +85,13 @@ function getWindowsAncestors(startPid, maxDepth = 4) {
82
85
  for (let i = 0; i < maxDepth; i++) {
83
86
  const proc = processes.get(currentPid);
84
87
  if (!proc) break;
85
- console.log(`[termbeam] Process tree: ${proc.name}`);
88
+ log.debug(`Process tree: ${proc.name}`);
86
89
  names.push(proc.name);
87
90
  if (!Number.isFinite(proc.ppid) || proc.ppid === 0 || proc.ppid === currentPid) break;
88
91
  currentPid = proc.ppid;
89
92
  }
90
93
  } catch (err) {
91
- console.log(`[termbeam] Could not query process tree: ${err.message}`);
94
+ log.debug(`Could not query process tree: ${err.message}`);
92
95
  }
93
96
 
94
97
  return names;
@@ -97,7 +100,7 @@ function getWindowsAncestors(startPid, maxDepth = 4) {
97
100
  function getDefaultShell() {
98
101
  const { execFileSync } = require('child_process');
99
102
  const ppid = process.ppid;
100
- console.log(`[termbeam] Detecting shell (parent PID: ${ppid}, platform: ${os.platform()})`);
103
+ log.debug(`Detecting shell (parent PID: ${ppid}, platform: ${os.platform()})`);
101
104
 
102
105
  if (os.platform() === 'win32') {
103
106
  // Walk up the process tree (up to 4 ancestors) to find the real user shell.
@@ -109,18 +112,18 @@ function getDefaultShell() {
109
112
  let foundCmd = false;
110
113
  for (const name of ancestors) {
111
114
  if (preferredShells.includes(name)) {
112
- console.log(`[termbeam] Found shell in process tree: ${name}`);
115
+ log.debug(`Found shell in process tree: ${name}`);
113
116
  return name;
114
117
  }
115
118
  if (name === 'cmd.exe') foundCmd = true;
116
119
  }
117
120
 
118
121
  if (foundCmd) {
119
- console.log(`[termbeam] Using detected shell: cmd.exe`);
122
+ log.debug(`Using detected shell: cmd.exe`);
120
123
  return 'cmd.exe';
121
124
  }
122
125
  const fallback = process.env.COMSPEC || 'cmd.exe';
123
- console.log(`[termbeam] Falling back to: ${fallback}`);
126
+ log.debug(`Falling back to: ${fallback}`);
124
127
  return fallback;
125
128
  }
126
129
 
@@ -134,22 +137,33 @@ function getDefaultShell() {
134
137
  const comm = result.trim();
135
138
  if (comm) {
136
139
  const shell = comm.startsWith('-') ? comm.slice(1) : comm;
137
- console.log(`[termbeam] Detected parent shell: ${shell}`);
140
+ log.debug(`Detected parent shell: ${shell}`);
138
141
  return shell;
139
142
  }
140
143
  } catch (err) {
141
- console.log(`[termbeam] Could not detect parent shell: ${err.message}`);
144
+ log.debug(`Could not detect parent shell: ${err.message}`);
142
145
  }
143
146
 
144
147
  // Fallback to SHELL env or /bin/sh
145
148
  const fallback = process.env.SHELL || '/bin/sh';
146
- console.log(`[termbeam] Falling back to: ${fallback}`);
149
+ log.debug(`Falling back to: ${fallback}`);
147
150
  return fallback;
148
151
  }
149
152
 
150
153
  function parseArgs() {
151
154
  let port = parseInt(process.env.PORT || '3456', 10);
152
155
  let host = '0.0.0.0';
156
+
157
+ // Resolve log level early (env + args) so shell detection logs are visible
158
+ let logLevel = process.env.TERMBEAM_LOG_LEVEL || 'info';
159
+ for (const arg of process.argv.slice(2)) {
160
+ if (arg.startsWith('--log-level=')) { logLevel = arg.split('=')[1]; break; }
161
+ }
162
+ for (let i = 2; i < process.argv.length; i++) {
163
+ if (process.argv[i] === '--log-level' && process.argv[i + 1]) { logLevel = process.argv[i + 1]; break; }
164
+ }
165
+ log.setLevel(logLevel);
166
+
153
167
  const defaultShell = getDefaultShell();
154
168
  const cwd = process.env.TERMBEAM_CWD || process.env.PTY_CWD || process.cwd();
155
169
  let password = process.env.TERMBEAM_PASSWORD || process.env.PTY_PASSWORD || null;
@@ -192,6 +206,8 @@ function parseArgs() {
192
206
  port = parseInt(args[++i], 10);
193
207
  } else if (args[i] === '--host' && args[i + 1]) {
194
208
  host = args[++i];
209
+ } else if (args[i] === '--log-level' && args[i + 1]) {
210
+ logLevel = args[++i];
195
211
  } else {
196
212
  filteredArgs.push(args[i]);
197
213
  }
@@ -211,7 +227,7 @@ function parseArgs() {
211
227
  const { getVersion } = require('./version');
212
228
  const version = getVersion();
213
229
 
214
- return { port, host, password, useTunnel, persistedTunnel, shell, shellArgs, cwd, defaultShell, version };
230
+ return { port, host, password, useTunnel, persistedTunnel, shell, shellArgs, cwd, defaultShell, version, logLevel };
215
231
  }
216
232
 
217
233
  module.exports = { parseArgs, printHelp };
package/src/logger.js ADDED
@@ -0,0 +1,32 @@
1
+ const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
2
+ const LABELS = { error: 'ERROR', warn: 'WARN', info: 'INFO', debug: 'DEBUG' };
3
+
4
+ let currentLevel = LEVELS.info;
5
+
6
+ function timestamp() {
7
+ return new Date().toLocaleTimeString('en-GB', { hour12: false });
8
+ }
9
+
10
+ const log = {
11
+ setLevel(level) {
12
+ const l = LEVELS[level];
13
+ if (l !== undefined) currentLevel = l;
14
+ },
15
+ getLevel() {
16
+ return Object.keys(LEVELS).find(k => LEVELS[k] === currentLevel);
17
+ },
18
+ error(...args) {
19
+ if (currentLevel >= LEVELS.error) console.error(`[${timestamp()}]`, `[${LABELS.error}]`, ...args);
20
+ },
21
+ warn(...args) {
22
+ if (currentLevel >= LEVELS.warn) console.warn(`[${timestamp()}]`, `[${LABELS.warn}]`, ...args);
23
+ },
24
+ info(...args) {
25
+ if (currentLevel >= LEVELS.info) console.log(`[${timestamp()}]`, `[${LABELS.info}]`, ...args);
26
+ },
27
+ debug(...args) {
28
+ if (currentLevel >= LEVELS.debug) console.log(`[${timestamp()}]`, `[${LABELS.debug}]`, ...args);
29
+ },
30
+ };
31
+
32
+ module.exports = log;
package/src/routes.js CHANGED
@@ -4,8 +4,10 @@ const fs = require('fs');
4
4
  const crypto = require('crypto');
5
5
  const express = require('express');
6
6
  const { detectShells } = require('./shells');
7
+ const log = require('./logger');
7
8
 
8
9
  const PUBLIC_DIR = path.join(__dirname, '..', 'public');
10
+ const uploadedFiles = [];
9
11
 
10
12
  function setupRoutes(app, { auth, sessions, config }) {
11
13
  // Serve static files (manifest.json, sw.js, icons, etc.)
@@ -28,10 +30,10 @@ function setupRoutes(app, { auth, sessions, config }) {
28
30
  maxAge: 24 * 60 * 60 * 1000,
29
31
  secure: false,
30
32
  });
31
- console.log(`[termbeam] Auth: login success from ${req.ip}`);
33
+ log.info(`Auth: login success from ${req.ip}`);
32
34
  res.json({ ok: true });
33
35
  } else {
34
- console.warn(`[termbeam] Auth: login failed from ${req.ip}`);
36
+ log.warn(`Auth: login failed from ${req.ip}`);
35
37
  res.status(401).json({ error: 'wrong password' });
36
38
  }
37
39
  });
@@ -55,6 +57,30 @@ function setupRoutes(app, { auth, sessions, config }) {
55
57
 
56
58
  app.post('/api/sessions', auth.middleware, (req, res) => {
57
59
  const { name, shell, args: shellArgs, cwd, initialCommand, color } = req.body || {};
60
+
61
+ // Validate shell field
62
+ if (shell) {
63
+ const availableShells = detectShells();
64
+ const isValid = availableShells.some(s => s.path === shell || s.cmd === shell);
65
+ if (!isValid) {
66
+ return res.status(400).json({ error: 'Invalid shell' });
67
+ }
68
+ }
69
+
70
+ // Validate cwd field
71
+ if (cwd) {
72
+ if (!path.isAbsolute(cwd)) {
73
+ return res.status(400).json({ error: 'cwd must be an absolute path' });
74
+ }
75
+ try {
76
+ if (!fs.statSync(cwd).isDirectory()) {
77
+ return res.status(400).json({ error: 'cwd is not a directory' });
78
+ }
79
+ } catch {
80
+ return res.status(400).json({ error: 'cwd does not exist' });
81
+ }
82
+ }
83
+
58
84
  const id = sessions.create({
59
85
  name: name || `Session ${sessions.sessions.size + 1}`,
60
86
  shell: shell || config.defaultShell,
@@ -96,7 +122,7 @@ function setupRoutes(app, { auth, sessions, config }) {
96
122
  app.post('/api/upload', auth.middleware, (req, res) => {
97
123
  const contentType = req.headers['content-type'] || '';
98
124
  if (!contentType.startsWith('image/')) {
99
- console.warn(`[termbeam] Upload rejected: invalid content-type "${contentType}"`);
125
+ log.warn(`Upload rejected: invalid content-type "${contentType}"`);
100
126
  return res.status(400).json({ error: 'Invalid content type' });
101
127
  }
102
128
 
@@ -110,7 +136,7 @@ function setupRoutes(app, { auth, sessions, config }) {
110
136
  size += chunk.length;
111
137
  if (size > limit) {
112
138
  aborted = true;
113
- console.warn(`[termbeam] Upload rejected: file too large (${size} bytes)`);
139
+ log.warn(`Upload rejected: file too large (${size} bytes)`);
114
140
  res.status(413).json({ error: 'File too large' });
115
141
  req.resume(); // drain remaining data
116
142
  return;
@@ -134,12 +160,13 @@ function setupRoutes(app, { auth, sessions, config }) {
134
160
  const filename = `termbeam-${crypto.randomUUID()}${ext}`;
135
161
  const filepath = path.join(os.tmpdir(), filename);
136
162
  fs.writeFileSync(filepath, buffer);
137
- console.log(`[termbeam] Upload: ${filename} (${buffer.length} bytes)`);
163
+ uploadedFiles.push(filepath);
164
+ log.info(`Upload: ${filename} (${buffer.length} bytes)`);
138
165
  res.json({ path: filepath });
139
166
  });
140
167
 
141
168
  req.on('error', (err) => {
142
- console.error(`[termbeam] Upload error: ${err.message}`);
169
+ log.error(`Upload error: ${err.message}`);
143
170
  res.status(500).json({ error: 'Upload failed' });
144
171
  });
145
172
  });
@@ -165,4 +192,17 @@ function setupRoutes(app, { auth, sessions, config }) {
165
192
  });
166
193
  }
167
194
 
168
- module.exports = { setupRoutes };
195
+ function cleanupUploadedFiles() {
196
+ for (const filepath of uploadedFiles) {
197
+ try {
198
+ if (fs.existsSync(filepath)) {
199
+ fs.unlinkSync(filepath);
200
+ }
201
+ } catch (err) {
202
+ log.error(`Failed to cleanup ${filepath}: ${err.message}`);
203
+ }
204
+ }
205
+ uploadedFiles.length = 0;
206
+ }
207
+
208
+ module.exports = { setupRoutes, cleanupUploadedFiles };
package/src/server.js CHANGED
@@ -10,57 +10,11 @@ const QRCode = require('qrcode');
10
10
  const { parseArgs } = require('./cli');
11
11
  const { createAuth } = require('./auth');
12
12
  const { SessionManager } = require('./sessions');
13
- const { setupRoutes } = require('./routes');
13
+ const { setupRoutes, cleanupUploadedFiles } = require('./routes');
14
14
  const { setupWebSocket } = require('./websocket');
15
15
  const { startTunnel, cleanupTunnel } = require('./tunnel');
16
16
 
17
- // --- Config ---
18
- const config = parseArgs();
19
- const auth = createAuth(config.password);
20
- const sessions = new SessionManager();
21
-
22
- // --- Express ---
23
- const app = express();
24
- app.use(express.json());
25
- app.use(cookieParser());
26
- app.use((_req, res, next) => {
27
- res.setHeader('X-Content-Type-Options', 'nosniff');
28
- res.setHeader('X-Frame-Options', 'DENY');
29
- res.setHeader('Referrer-Policy', 'no-referrer');
30
- res.setHeader('Cache-Control', 'no-store');
31
- res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws: wss:; font-src 'self' https://cdn.jsdelivr.net");
32
- next();
33
- });
34
-
35
- const server = http.createServer(app);
36
- const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
37
-
38
- setupRoutes(app, { auth, sessions, config });
39
- setupWebSocket(wss, { auth, sessions });
40
-
41
- // --- Lifecycle ---
42
- let shuttingDown = false;
43
- function shutdown() {
44
- if (shuttingDown) return;
45
- shuttingDown = true;
46
- console.log('\n[termbeam] Shutting down...');
47
- sessions.shutdown();
48
- cleanupTunnel();
49
- server.close();
50
- wss.close();
51
- // Force exit after giving connections time to close
52
- setTimeout(() => process.exit(0), 500).unref();
53
- }
54
-
55
- process.on('SIGINT', shutdown);
56
- process.on('SIGTERM', shutdown);
57
- process.on('uncaughtException', (err) => {
58
- console.error('[termbeam] Uncaught exception:', err.message);
59
- cleanupTunnel();
60
- process.exit(1);
61
- });
62
-
63
- // --- Start ---
17
+ // --- Helpers ---
64
18
  function getLocalIP() {
65
19
  const interfaces = os.networkInterfaces();
66
20
  for (const name of Object.keys(interfaces)) {
@@ -71,80 +25,163 @@ function getLocalIP() {
71
25
  return '127.0.0.1';
72
26
  }
73
27
 
74
- server.listen(config.port, config.host, async () => {
75
- const ip = getLocalIP();
76
- const localUrl = `http://${ip}:${config.port}`;
77
-
78
- const defaultId = sessions.create({
79
- name: path.basename(config.cwd),
80
- shell: config.shell,
81
- args: config.shellArgs,
82
- cwd: config.cwd,
28
+ /**
29
+ * Create a TermBeam server instance without starting it.
30
+ * @param {object} [overrides] - Optional overrides
31
+ * @param {object} [overrides.config] - Full config object (skips parseArgs)
32
+ * @returns {{ app, server, wss, sessions, config, auth, start, shutdown }}
33
+ */
34
+ function createTermBeamServer(overrides = {}) {
35
+ const config = overrides.config || parseArgs();
36
+ const log = require('./logger');
37
+ if (config.logLevel) log.setLevel(config.logLevel);
38
+ const auth = createAuth(config.password);
39
+ const sessions = new SessionManager();
40
+
41
+ // --- Express ---
42
+ const app = express();
43
+ app.use(express.json());
44
+ app.use(cookieParser());
45
+ app.use((_req, res, next) => {
46
+ res.setHeader('X-Content-Type-Options', 'nosniff');
47
+ res.setHeader('X-Frame-Options', 'DENY');
48
+ res.setHeader('Referrer-Policy', 'no-referrer');
49
+ res.setHeader('Cache-Control', 'no-store');
50
+ res.setHeader(
51
+ 'Content-Security-Policy',
52
+ "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; connect-src 'self' ws: wss:; font-src 'self' https://cdn.jsdelivr.net",
53
+ );
54
+ next();
83
55
  });
84
56
 
85
- const lp = '\x1b[38;5;141m'; // light purple
86
- const rs = '\x1b[0m'; // reset
87
- console.log('');
88
- console.log(
89
- `${lp} ████████╗███████╗██████╗ ███╗ ███╗██████╗ ███████╗ █████╗ ███╗ ███╗${rs}`,
90
- );
91
- console.log(
92
- `${lp} ╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██╔══██╗██╔════╝██╔══██╗████╗ ████║${rs}`,
93
- );
94
- console.log(
95
- `${lp} ██║ █████╗ ██████╔╝██╔████╔██║██████╔╝█████╗ ███████║██╔████╔██║${rs}`,
96
- );
97
- console.log(
98
- `${lp} ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║${rs}`,
99
- );
100
- console.log(
101
- `${lp} ██║ ███████╗██║ ██║██║ ╚═╝ ██║██████╔╝███████╗██║ ██║██║ ╚═╝ ██║${rs}`,
102
- );
103
- console.log(
104
- `${lp} ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝${rs}`,
105
- );
106
- console.log('');
107
- console.log(` Beam your terminal to any device 📡 v${config.version}`);
108
- console.log('');
109
- const isLanReachable = config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
110
- const gn = '\x1b[38;5;114m'; // green
111
- const dm = '\x1b[2m'; // dim
112
-
113
- let publicUrl = null;
114
- if (config.useTunnel) {
115
- const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
116
- if (tunnel) {
117
- publicUrl = tunnel.url;
118
- } else {
119
- console.log(' ⚠️ Tunnel failed to start. Using LAN only.');
120
- }
57
+ const server = http.createServer(app);
58
+ const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
59
+
60
+ setupRoutes(app, { auth, sessions, config });
61
+ setupWebSocket(wss, { auth, sessions });
62
+
63
+ // --- Lifecycle ---
64
+ let shuttingDown = false;
65
+ function shutdown() {
66
+ if (shuttingDown) return;
67
+ shuttingDown = true;
68
+ sessions.shutdown();
69
+ cleanupUploadedFiles();
70
+ cleanupTunnel();
71
+ server.close();
72
+ wss.close();
121
73
  }
122
74
 
123
- console.log(` Shell: ${config.shell}`);
124
- console.log(` Session: ${defaultId}`);
125
- console.log(` Auth: ${config.password ? `${gn}🔒 password${rs}` : '🔓 none'}`);
126
- console.log('');
127
-
128
- if (publicUrl) {
129
- console.log(` 🌐 Public: ${publicUrl}`);
130
- }
131
- console.log(` Local: http://localhost:${config.port}`);
132
- if (isLanReachable) {
133
- console.log(` LAN: ${localUrl}`);
75
+ function start() {
76
+ return new Promise((resolve) => {
77
+ server.listen(config.port, config.host, async () => {
78
+ const ip = getLocalIP();
79
+ const localUrl = `http://${ip}:${config.port}`;
80
+
81
+ const defaultId = sessions.create({
82
+ name: path.basename(config.cwd),
83
+ shell: config.shell,
84
+ args: config.shellArgs,
85
+ cwd: config.cwd,
86
+ });
87
+
88
+ const lp = '\x1b[38;5;141m'; // light purple
89
+ const rs = '\x1b[0m'; // reset
90
+ console.log('');
91
+ console.log(
92
+ `${lp} ████████╗███████╗██████╗ ███╗ ███╗██████╗ ███████╗ █████╗ ███╗ ███╗${rs}`,
93
+ );
94
+ console.log(
95
+ `${lp} ╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██╔══██╗██╔════╝██╔══██╗████╗ ████║${rs}`,
96
+ );
97
+ console.log(
98
+ `${lp} ██║ █████╗ ██████╔╝██╔████╔██║██████╔╝█████╗ ███████║██╔████╔██║${rs}`,
99
+ );
100
+ console.log(
101
+ `${lp} ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══██╗██╔══╝ ██╔══██║██║╚██╔╝██║${rs}`,
102
+ );
103
+ console.log(
104
+ `${lp} ██║ ███████╗██║ ██║██║ ╚═╝ ██║██████╔╝███████╗██║ ██║██║ ╚═╝ ██║${rs}`,
105
+ );
106
+ console.log(
107
+ `${lp} ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝${rs}`,
108
+ );
109
+ console.log('');
110
+ console.log(` Beam your terminal to any device 📡 v${config.version}`);
111
+ console.log('');
112
+ const isLanReachable =
113
+ config.host === '0.0.0.0' || config.host === '::' || config.host === ip;
114
+ const gn = '\x1b[38;5;114m'; // green
115
+ const dm = '\x1b[2m'; // dim
116
+
117
+ let publicUrl = null;
118
+ if (config.useTunnel) {
119
+ const tunnel = await startTunnel(config.port, { persisted: config.persistedTunnel });
120
+ if (tunnel) {
121
+ publicUrl = tunnel.url;
122
+ } else {
123
+ console.log(' ⚠️ Tunnel failed to start. Using LAN only.');
124
+ }
125
+ }
126
+
127
+ console.log(` Shell: ${config.shell}`);
128
+ console.log(` Session: ${defaultId}`);
129
+ console.log(` Auth: ${config.password ? `${gn}🔒 password${rs}` : '🔓 none'}`);
130
+ console.log('');
131
+
132
+ if (publicUrl) {
133
+ console.log(` 🌐 Public: ${publicUrl}`);
134
+ }
135
+ console.log(` Local: http://localhost:${config.port}`);
136
+ if (isLanReachable) {
137
+ console.log(` LAN: ${localUrl}`);
138
+ }
139
+
140
+ const qrUrl = publicUrl || (isLanReachable ? localUrl : `http://localhost:${config.port}`);
141
+ console.log('');
142
+ console.log(` ${dm}📋 Clipboard requires HTTPS — use the Public or localhost URL${rs}`);
143
+ console.log('');
144
+ try {
145
+ const qr = await QRCode.toString(qrUrl, { type: 'terminal', small: true });
146
+ console.log(qr);
147
+ } catch {
148
+ /* ignore */
149
+ }
150
+
151
+ console.log(` Scan the QR code or open: ${qrUrl}`);
152
+ if (config.password) console.log(` Password: ${gn}${config.password}${rs}`);
153
+ console.log('');
154
+
155
+ resolve({ url: `http://localhost:${config.port}`, defaultId });
156
+ });
157
+ });
134
158
  }
135
159
 
136
- const qrUrl = publicUrl || (isLanReachable ? localUrl : `http://localhost:${config.port}`);
137
- console.log('');
138
- console.log(` ${dm}📋 Clipboard requires HTTPS — use the Public or localhost URL${rs}`);
139
- console.log('');
140
- try {
141
- const qr = await QRCode.toString(qrUrl, { type: 'terminal', small: true });
142
- console.log(qr);
143
- } catch {
144
- /* ignore */
145
- }
160
+ return { app, server, wss, sessions, config, auth, start, shutdown };
161
+ }
162
+
163
+ module.exports = { createTermBeamServer };
164
+
165
+ // Auto-start when run directly (CLI entry point)
166
+ const _entryBase = path.basename(process.argv[1] || '');
167
+ if (require.main === module || _entryBase === 'termbeam' || _entryBase === 'termbeam.js') {
168
+ const instance = createTermBeamServer();
169
+
170
+ process.on('SIGINT', () => {
171
+ console.log('\n[termbeam] Shutting down...');
172
+ instance.shutdown();
173
+ setTimeout(() => process.exit(0), 500).unref();
174
+ });
175
+ process.on('SIGTERM', () => {
176
+ console.log('\n[termbeam] Shutting down...');
177
+ instance.shutdown();
178
+ setTimeout(() => process.exit(0), 500).unref();
179
+ });
180
+ process.on('uncaughtException', (err) => {
181
+ console.error('[termbeam] Uncaught exception:', err.message);
182
+ cleanupTunnel();
183
+ process.exit(1);
184
+ });
146
185
 
147
- console.log(` Scan the QR code or open: ${qrUrl}`);
148
- if (config.password) console.log(` Password: ${gn}${config.password}${rs}`);
149
- console.log('');
150
- });
186
+ instance.start();
187
+ }
package/src/sessions.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const crypto = require('crypto');
2
2
  const pty = require('node-pty');
3
+ const log = require('./logger');
3
4
 
4
5
  const SESSION_COLORS = [
5
6
  '#4a9eff', '#4ade80', '#fbbf24', '#c084fc',
@@ -12,7 +13,7 @@ class SessionManager {
12
13
  }
13
14
 
14
15
  create({ name, shell, args = [], cwd, initialCommand = null, color = null }) {
15
- const id = crypto.randomBytes(4).toString('hex');
16
+ const id = crypto.randomBytes(16).toString('hex');
16
17
  if (!color) {
17
18
  color = SESSION_COLORS[this.sessions.size % SESSION_COLORS.length];
18
19
  }
@@ -55,7 +56,7 @@ class SessionManager {
55
56
  });
56
57
 
57
58
  ptyProcess.onExit(({ exitCode }) => {
58
- console.log(`[termbeam] Session "${name}" (${id}) exited (code ${exitCode})`);
59
+ log.info(`Session "${name}" (${id}) exited (code ${exitCode})`);
59
60
  for (const ws of session.clients) {
60
61
  if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'exit', code: exitCode }));
61
62
  }
@@ -63,7 +64,7 @@ class SessionManager {
63
64
  });
64
65
 
65
66
  this.sessions.set(id, session);
66
- console.log(`[termbeam] Session "${name}" created (id=${id}, pid=${ptyProcess.pid})`);
67
+ log.info(`Session "${name}" created (id=${id}, pid=${ptyProcess.pid})`);
67
68
  return id;
68
69
  }
69
70
 
@@ -82,7 +83,7 @@ class SessionManager {
82
83
  delete(id) {
83
84
  const s = this.sessions.get(id);
84
85
  if (!s) return false;
85
- console.log(`[termbeam] Session "${s.name}" deleted (id=${id})`);
86
+ log.info(`Session "${s.name}" deleted (id=${id})`);
86
87
  s.pty.kill();
87
88
  return true;
88
89
  }
package/src/tunnel.js CHANGED
@@ -2,6 +2,7 @@ const { execSync, execFileSync, spawn } = require('child_process');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
+ const log = require('./logger');
5
6
 
6
7
  const TUNNEL_CONFIG_DIR = path.join(os.homedir(), '.termbeam');
7
8
  const TUNNEL_CONFIG_PATH = path.join(TUNNEL_CONFIG_DIR, 'tunnel.json');
@@ -55,7 +56,7 @@ function deletePersisted() {
55
56
  try {
56
57
  if (SAFE_ID_RE.test(persisted.tunnelId)) {
57
58
  execFileSync(devtunnelCmd, ['delete', persisted.tunnelId, '-f'], { stdio: 'pipe' });
58
- console.log(`[termbeam] Deleted persisted tunnel ${persisted.tunnelId}`);
59
+ log.info(`Deleted persisted tunnel ${persisted.tunnelId}`);
59
60
  }
60
61
  } catch {}
61
62
  try {
@@ -80,24 +81,24 @@ async function startTunnel(port, options = {}) {
80
81
  // Check if devtunnel CLI is installed
81
82
  const found = findDevtunnel();
82
83
  if (!found) {
83
- console.error('[termbeam] ❌ devtunnel CLI is not installed.');
84
- console.error('');
85
- console.error(' The --tunnel flag requires the Azure Dev Tunnels CLI.');
86
- console.error('');
87
- console.error(' Install it:');
88
- console.error(' Windows: winget install Microsoft.devtunnel');
89
- console.error(' or: Invoke-WebRequest -Uri https://aka.ms/TunnelsCliDownload/win-x64 -OutFile devtunnel.exe');
90
- console.error(' macOS: brew install --cask devtunnel');
91
- console.error(' Linux: curl -sL https://aka.ms/DevTunnelCliInstall | bash');
92
- console.error('');
93
- console.error(' Then restart your terminal and try again.');
94
- console.error(' Docs: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started');
95
- console.error('');
84
+ log.error('❌ devtunnel CLI is not installed.');
85
+ log.error('');
86
+ log.error(' The --tunnel flag requires the Azure Dev Tunnels CLI.');
87
+ log.error('');
88
+ log.error(' Install it:');
89
+ log.error(' Windows: winget install Microsoft.devtunnel');
90
+ log.error(' or: Invoke-WebRequest -Uri https://aka.ms/TunnelsCliDownload/win-x64 -OutFile devtunnel.exe');
91
+ log.error(' macOS: brew install --cask devtunnel');
92
+ log.error(' Linux: curl -sL https://aka.ms/DevTunnelCliInstall | bash');
93
+ log.error('');
94
+ log.error(' Then restart your terminal and try again.');
95
+ log.error(' Docs: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started');
96
+ log.error('');
96
97
  return null;
97
98
  }
98
99
  devtunnelCmd = found;
99
100
 
100
- console.log('[termbeam] Starting devtunnel...');
101
+ log.info('Starting devtunnel...');
101
102
  try {
102
103
  // Ensure user is logged in
103
104
  let loggedIn = false;
@@ -108,8 +109,8 @@ async function startTunnel(port, options = {}) {
108
109
  } catch {}
109
110
 
110
111
  if (!loggedIn) {
111
- console.log('[termbeam] devtunnel not logged in, launching login...');
112
- console.log('[termbeam] A browser window will open for authentication.');
112
+ log.info('devtunnel not logged in, launching login...');
113
+ log.info('A browser window will open for authentication.');
113
114
  execFileSync(devtunnelCmd, ['user', 'login'], { stdio: 'inherit' });
114
115
  }
115
116
 
@@ -124,16 +125,16 @@ async function startTunnel(port, options = {}) {
124
125
  const saved = loadPersistedTunnel();
125
126
  if (saved && isTunnelValid(saved.tunnelId)) {
126
127
  tunnelId = saved.tunnelId;
127
- console.log(`[termbeam] Reusing persisted tunnel ${tunnelId}`);
128
+ log.info(`Reusing persisted tunnel ${tunnelId}`);
128
129
  } else {
129
130
  if (saved) {
130
- console.log('[termbeam] Persisted tunnel expired, creating new one');
131
+ log.info('Persisted tunnel expired, creating new one');
131
132
  }
132
133
  const createOut = execFileSync(devtunnelCmd, ['create', '--expiration', '30d', '--json'], { encoding: 'utf-8' });
133
134
  const tunnelData = JSON.parse(createOut);
134
135
  tunnelId = tunnelData.tunnel.tunnelId;
135
136
  savePersistedTunnel(tunnelId);
136
- console.log(`[termbeam] Created new persisted tunnel ${tunnelId}`);
137
+ log.info(`Created new persisted tunnel ${tunnelId}`);
137
138
  }
138
139
  } else {
139
140
  tunnelMode = 'ephemeral';
@@ -142,7 +143,7 @@ async function startTunnel(port, options = {}) {
142
143
  const createOut = execFileSync(devtunnelCmd, ['create', '--expiration', '1d', '--json'], { encoding: 'utf-8' });
143
144
  const tunnelData = JSON.parse(createOut);
144
145
  tunnelId = tunnelData.tunnel.tunnelId;
145
- console.log(`[termbeam] Created ephemeral tunnel ${tunnelId}`);
146
+ log.info(`Created ephemeral tunnel ${tunnelId}`);
146
147
  }
147
148
 
148
149
  // Idempotent port and access setup
@@ -174,13 +175,13 @@ async function startTunnel(port, options = {}) {
174
175
  output += data.toString();
175
176
  });
176
177
  hostProc.on('error', (err) => {
177
- console.error(`[termbeam] Tunnel process error: ${err.message}`);
178
+ log.error(`Tunnel process error: ${err.message}`);
178
179
  clearTimeout(timeout);
179
180
  resolve(null);
180
181
  });
181
182
  });
182
183
  } catch (e) {
183
- console.error(`[termbeam] Tunnel error: ${e.message}`);
184
+ log.error(`Tunnel error: ${e.message}`);
184
185
  return null;
185
186
  }
186
187
  }
@@ -205,11 +206,11 @@ function cleanupTunnel() {
205
206
  if (id) {
206
207
  tunnelId = null;
207
208
  if (isPersisted) {
208
- console.log('[termbeam] Tunnel host stopped (tunnel preserved for reuse)');
209
+ log.info('Tunnel host stopped (tunnel preserved for reuse)');
209
210
  } else {
210
211
  try {
211
212
  execFileSync(devtunnelCmd, ['delete', id, '-f'], { stdio: 'pipe', timeout: 10000 });
212
- console.log('[termbeam] Tunnel cleaned up');
213
+ log.info('Tunnel cleaned up');
213
214
  } catch {
214
215
  /* best effort — tunnel will expire on its own */
215
216
  }
package/src/websocket.js CHANGED
@@ -1,3 +1,5 @@
1
+ const log = require('./logger');
2
+
1
3
  function recalcPtySize(session) {
2
4
  let minCols = Infinity;
3
5
  let minRows = Infinity;
@@ -16,6 +18,23 @@ function recalcPtySize(session) {
16
18
 
17
19
  function setupWebSocket(wss, { auth, sessions }) {
18
20
  wss.on('connection', (ws, req) => {
21
+ const origin = req.headers.origin;
22
+ if (origin) {
23
+ try {
24
+ const originHost = new URL(origin).hostname;
25
+ const reqHost = (req.headers.host || '').split(':')[0];
26
+ if (originHost !== reqHost && originHost !== 'localhost' && reqHost !== 'localhost') {
27
+ log.warn(`WS: rejected cross-origin connection from ${origin}`);
28
+ ws.close(1008, 'Origin not allowed');
29
+ return;
30
+ }
31
+ } catch {
32
+ log.warn(`WS: rejected invalid origin: ${origin}`);
33
+ ws.close(1008, 'Invalid origin');
34
+ return;
35
+ }
36
+ }
37
+
19
38
  let authenticated = !auth.password;
20
39
  let attached = null;
21
40
 
@@ -35,9 +54,9 @@ function setupWebSocket(wss, { auth, sessions }) {
35
54
  if (msg.password === auth.password || auth.validateToken(msg.token)) {
36
55
  authenticated = true;
37
56
  ws.send(JSON.stringify({ type: 'auth_ok' }));
38
- console.log('[termbeam] WS: auth success');
57
+ log.info('WS: auth success');
39
58
  } else {
40
- console.warn('[termbeam] WS: auth failed');
59
+ log.warn('WS: auth failed');
41
60
  ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
42
61
  ws.close();
43
62
  }
@@ -54,7 +73,7 @@ function setupWebSocket(wss, { auth, sessions }) {
54
73
  const session = sessions.get(msg.sessionId);
55
74
  if (!session) {
56
75
  ws.send(JSON.stringify({ type: 'error', message: 'Session not found' }));
57
- console.warn(`[termbeam] WS: attach failed — session ${msg.sessionId} not found`);
76
+ log.warn(`WS: attach failed — session ${msg.sessionId} not found`);
58
77
  return;
59
78
  }
60
79
  attached = session;
@@ -63,7 +82,7 @@ function setupWebSocket(wss, { auth, sessions }) {
63
82
  ws.send(JSON.stringify({ type: 'output', data: session.scrollbackBuf }));
64
83
  }
65
84
  ws.send(JSON.stringify({ type: 'attached', sessionId: msg.sessionId }));
66
- console.log(`[termbeam] Client attached to session ${msg.sessionId}`);
85
+ log.info(`Client attached to session ${msg.sessionId}`);
67
86
  return;
68
87
  }
69
88
 
@@ -80,7 +99,7 @@ function setupWebSocket(wss, { auth, sessions }) {
80
99
  }
81
100
  }
82
101
  } catch (err) {
83
- console.warn('WS: dropped unparseable message:', err.message);
102
+ log.warn(`WS: dropped unparseable message: ${err.message}`);
84
103
  }
85
104
  });
86
105
 
@@ -88,7 +107,7 @@ function setupWebSocket(wss, { auth, sessions }) {
88
107
  if (attached) {
89
108
  attached.clients.delete(ws);
90
109
  recalcPtySize(attached);
91
- console.log('[termbeam] Client detached');
110
+ log.info('Client detached');
92
111
  }
93
112
  });
94
113
  });