termbeam 0.0.9 → 0.1.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/src/auth.js CHANGED
@@ -61,6 +61,19 @@ function createAuth(password) {
61
61
  const tokens = new Map();
62
62
  const authAttempts = new Map();
63
63
 
64
+ // Periodically clean up expired tokens and stale rate-limit entries
65
+ setInterval(() => {
66
+ const now = Date.now();
67
+ for (const [token, expiry] of tokens) {
68
+ if (now > expiry) tokens.delete(token);
69
+ }
70
+ for (const [ip, attempts] of authAttempts) {
71
+ const recent = attempts.filter((t) => now - t < 60 * 1000);
72
+ if (recent.length === 0) authAttempts.delete(ip);
73
+ else authAttempts.set(ip, recent);
74
+ }
75
+ }, 60 * 60 * 1000).unref();
76
+
64
77
  function generateToken() {
65
78
  const token = crypto.randomBytes(32).toString('hex');
66
79
  tokens.set(token, Date.now() + 24 * 60 * 60 * 1000);
package/src/routes.js CHANGED
@@ -51,13 +51,14 @@ function setupRoutes(app, { auth, sessions, config }) {
51
51
  });
52
52
 
53
53
  app.post('/api/sessions', auth.middleware, (req, res) => {
54
- const { name, shell, args: shellArgs, cwd, initialCommand } = req.body || {};
54
+ const { name, shell, args: shellArgs, cwd, initialCommand, color } = req.body || {};
55
55
  const id = sessions.create({
56
56
  name: name || `Session ${sessions.sessions.size + 1}`,
57
57
  shell: shell || config.defaultShell,
58
58
  args: shellArgs || [],
59
59
  cwd: cwd || config.cwd,
60
60
  initialCommand: initialCommand || null,
61
+ color: color || null,
61
62
  });
62
63
  res.json({ id, url: `/terminal?id=${id}` });
63
64
  });
@@ -76,6 +77,18 @@ function setupRoutes(app, { auth, sessions, config }) {
76
77
  }
77
78
  });
78
79
 
80
+ app.patch('/api/sessions/:id', auth.middleware, (req, res) => {
81
+ const { color, name } = req.body || {};
82
+ const updates = {};
83
+ if (color !== undefined) updates.color = color;
84
+ if (name !== undefined) updates.name = name;
85
+ if (sessions.update(req.params.id, updates)) {
86
+ res.json({ ok: true });
87
+ } else {
88
+ res.status(404).json({ error: 'not found' });
89
+ }
90
+ });
91
+
79
92
  // Directory listing for folder browser
80
93
  app.get('/api/dirs', auth.middleware, (req, res) => {
81
94
  const query = req.query.q || os.homedir();
package/src/sessions.js CHANGED
@@ -1,13 +1,21 @@
1
1
  const crypto = require('crypto');
2
2
  const pty = require('node-pty');
3
3
 
4
+ const SESSION_COLORS = [
5
+ '#4a9eff', '#4ade80', '#fbbf24', '#c084fc',
6
+ '#f87171', '#22d3ee', '#fb923c', '#f472b6',
7
+ ];
8
+
4
9
  class SessionManager {
5
10
  constructor() {
6
11
  this.sessions = new Map();
7
12
  }
8
13
 
9
- create({ name, shell, args = [], cwd, initialCommand = null }) {
14
+ create({ name, shell, args = [], cwd, initialCommand = null, color = null }) {
10
15
  const id = crypto.randomBytes(4).toString('hex');
16
+ if (!color) {
17
+ color = SESSION_COLORS[this.sessions.size % SESSION_COLORS.length];
18
+ }
11
19
  const ptyProcess = pty.spawn(shell, args, {
12
20
  name: 'xterm-256color',
13
21
  cols: 120,
@@ -26,14 +34,21 @@ class SessionManager {
26
34
  name,
27
35
  shell,
28
36
  cwd,
37
+ color,
29
38
  createdAt: new Date().toISOString(),
39
+ lastActivity: Date.now(),
30
40
  clients: new Set(),
31
41
  scrollback: [],
42
+ scrollbackBuf: '',
32
43
  };
33
44
 
34
45
  ptyProcess.onData((data) => {
35
- session.scrollback.push(data);
36
- if (session.scrollback.length > 2000) session.scrollback.shift();
46
+ session.lastActivity = Date.now();
47
+ session.scrollbackBuf += data;
48
+ // Cap scrollback at ~200KB
49
+ if (session.scrollbackBuf.length > 200000) {
50
+ session.scrollbackBuf = session.scrollbackBuf.slice(-100000);
51
+ }
37
52
  for (const ws of session.clients) {
38
53
  if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
39
54
  }
@@ -56,11 +71,18 @@ class SessionManager {
56
71
  return this.sessions.get(id);
57
72
  }
58
73
 
74
+ update(id, fields) {
75
+ const s = this.sessions.get(id);
76
+ if (!s) return false;
77
+ if (fields.color !== undefined) s.color = fields.color;
78
+ if (fields.name !== undefined) s.name = fields.name;
79
+ return true;
80
+ }
81
+
59
82
  delete(id) {
60
83
  const s = this.sessions.get(id);
61
84
  if (!s) return false;
62
85
  s.pty.kill();
63
- this.sessions.delete(id);
64
86
  return true;
65
87
  }
66
88
 
@@ -75,6 +97,8 @@ class SessionManager {
75
97
  pid: s.pty.pid,
76
98
  clients: s.clients.size,
77
99
  createdAt: s.createdAt,
100
+ color: s.color,
101
+ lastActivity: s.lastActivity,
78
102
  });
79
103
  }
80
104
  return list;
package/src/shells.js CHANGED
@@ -74,4 +74,4 @@ function detectUnixShells() {
74
74
  return shells;
75
75
  }
76
76
 
77
- module.exports = { detectShells };
77
+ module.exports = { detectShells, detectWindowsShells, detectUnixShells };
package/src/tunnel.js CHANGED
@@ -1,4 +1,4 @@
1
- const { execSync, spawn } = require('child_process');
1
+ 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');
@@ -10,6 +10,8 @@ let tunnelId = null;
10
10
  let tunnelProc = null;
11
11
  let devtunnelCmd = 'devtunnel';
12
12
 
13
+ const SAFE_ID_RE = /^[a-zA-Z0-9._-]+$/;
14
+
13
15
  function findDevtunnel() {
14
16
  // Try devtunnel directly
15
17
  try {
@@ -51,8 +53,10 @@ function deletePersisted() {
51
53
  const persisted = loadPersistedTunnel();
52
54
  if (persisted) {
53
55
  try {
54
- execSync(`"${devtunnelCmd}" delete ${persisted.tunnelId} -f`, { stdio: 'pipe' });
55
- console.log(`[termbeam] Deleted persisted tunnel ${persisted.tunnelId}`);
56
+ if (SAFE_ID_RE.test(persisted.tunnelId)) {
57
+ execFileSync(devtunnelCmd, ['delete', persisted.tunnelId, '-f'], { stdio: 'pipe' });
58
+ console.log(`[termbeam] Deleted persisted tunnel ${persisted.tunnelId}`);
59
+ }
56
60
  } catch {}
57
61
  try {
58
62
  fs.unlinkSync(TUNNEL_CONFIG_PATH);
@@ -62,7 +66,8 @@ function deletePersisted() {
62
66
 
63
67
  function isTunnelValid(id) {
64
68
  try {
65
- execSync(`"${devtunnelCmd}" show ${id} --json`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
69
+ if (!SAFE_ID_RE.test(id)) return false;
70
+ execFileSync(devtunnelCmd, ['show', id, '--json'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
66
71
  return true;
67
72
  } catch {
68
73
  return false;
@@ -97,7 +102,7 @@ async function startTunnel(port, options = {}) {
97
102
  // Ensure user is logged in
98
103
  let loggedIn = false;
99
104
  try {
100
- const userOut = execSync(`"${devtunnelCmd}" user show`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
105
+ const userOut = execFileSync(devtunnelCmd, ['user', 'show'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
101
106
  // user show can succeed but show "not logged in" status
102
107
  loggedIn = userOut && !userOut.toLowerCase().includes('not logged in');
103
108
  } catch {}
@@ -105,7 +110,7 @@ async function startTunnel(port, options = {}) {
105
110
  if (!loggedIn) {
106
111
  console.log('[termbeam] devtunnel not logged in, launching login...');
107
112
  console.log('[termbeam] A browser window will open for authentication.');
108
- execSync(`"${devtunnelCmd}" user login`, { stdio: 'inherit' });
113
+ execFileSync(devtunnelCmd, ['user', 'login'], { stdio: 'inherit' });
109
114
  }
110
115
 
111
116
  const persisted = options.persisted;
@@ -124,7 +129,7 @@ async function startTunnel(port, options = {}) {
124
129
  if (saved) {
125
130
  console.log('[termbeam] Persisted tunnel expired, creating new one');
126
131
  }
127
- const createOut = execSync(`"${devtunnelCmd}" create --expiration 30d --json`, { encoding: 'utf-8' });
132
+ const createOut = execFileSync(devtunnelCmd, ['create', '--expiration', '30d', '--json'], { encoding: 'utf-8' });
128
133
  const tunnelData = JSON.parse(createOut);
129
134
  tunnelId = tunnelData.tunnel.tunnelId;
130
135
  savePersistedTunnel(tunnelId);
@@ -134,7 +139,7 @@ async function startTunnel(port, options = {}) {
134
139
  tunnelMode = 'ephemeral';
135
140
  tunnelExpiry = '1 day';
136
141
  // Ephemeral tunnel — create fresh, will be deleted on shutdown
137
- const createOut = execSync(`"${devtunnelCmd}" create --expiration 1d --json`, { encoding: 'utf-8' });
142
+ const createOut = execFileSync(devtunnelCmd, ['create', '--expiration', '1d', '--json'], { encoding: 'utf-8' });
138
143
  const tunnelData = JSON.parse(createOut);
139
144
  tunnelId = tunnelData.tunnel.tunnelId;
140
145
  console.log(`[termbeam] Created ephemeral tunnel ${tunnelId}`);
@@ -142,10 +147,10 @@ async function startTunnel(port, options = {}) {
142
147
 
143
148
  // Idempotent port and access setup
144
149
  try {
145
- execSync(`"${devtunnelCmd}" port create ${tunnelId} -p ${port} --protocol http`, { stdio: 'pipe' });
150
+ execFileSync(devtunnelCmd, ['port', 'create', tunnelId, '-p', String(port), '--protocol', 'http'], { stdio: 'pipe' });
146
151
  } catch {}
147
152
  try {
148
- execSync(`"${devtunnelCmd}" access create ${tunnelId} -p ${port} --anonymous`, { stdio: 'pipe' });
153
+ execFileSync(devtunnelCmd, ['access', 'create', tunnelId, '-p', String(port), '--anonymous'], { stdio: 'pipe' });
149
154
  } catch {}
150
155
 
151
156
  const hostProc = spawn(devtunnelCmd, ['host', tunnelId], {
@@ -186,7 +191,7 @@ function cleanupTunnel() {
186
191
  // On Windows, kill the process tree to ensure all children die
187
192
  if (process.platform === 'win32' && tunnelProc.pid) {
188
193
  try {
189
- execSync(`taskkill /pid ${tunnelProc.pid} /T /F`, { stdio: 'pipe', timeout: 5000 });
194
+ execFileSync('taskkill', ['/pid', String(tunnelProc.pid), '/T', '/F'], { stdio: 'pipe', timeout: 5000 });
190
195
  } catch { /* best effort */ }
191
196
  } else {
192
197
  tunnelProc.kill('SIGKILL');
@@ -202,7 +207,7 @@ function cleanupTunnel() {
202
207
  console.log('[termbeam] Tunnel host stopped (tunnel preserved for reuse)');
203
208
  } else {
204
209
  try {
205
- execSync(`"${devtunnelCmd}" delete ${id} -f`, { stdio: 'pipe', timeout: 10000 });
210
+ execFileSync(devtunnelCmd, ['delete', id, '-f'], { stdio: 'pipe', timeout: 10000 });
206
211
  console.log('[termbeam] Tunnel cleaned up');
207
212
  } catch {
208
213
  /* best effort — tunnel will expire on its own */
package/src/websocket.js CHANGED
@@ -1,3 +1,19 @@
1
+ function recalcPtySize(session) {
2
+ let minCols = Infinity;
3
+ let minRows = Infinity;
4
+ for (const client of session.clients) {
5
+ if (client._dims) {
6
+ minCols = Math.min(minCols, client._dims.cols);
7
+ minRows = Math.min(minRows, client._dims.rows);
8
+ }
9
+ }
10
+ if (minCols === Infinity || minRows === Infinity) return;
11
+ if (minCols === session._lastCols && minRows === session._lastRows) return;
12
+ session._lastCols = minCols;
13
+ session._lastRows = minRows;
14
+ session.pty.resize(minCols, minRows);
15
+ }
16
+
1
17
  function setupWebSocket(wss, { auth, sessions }) {
2
18
  wss.on('connection', (ws, req) => {
3
19
  let authenticated = !auth.password;
@@ -40,8 +56,8 @@ function setupWebSocket(wss, { auth, sessions }) {
40
56
  }
41
57
  attached = session;
42
58
  session.clients.add(ws);
43
- if (session.scrollback.length > 0) {
44
- ws.send(JSON.stringify({ type: 'output', data: session.scrollback.join('') }));
59
+ if (session.scrollbackBuf.length > 0) {
60
+ ws.send(JSON.stringify({ type: 'output', data: session.scrollbackBuf }));
45
61
  }
46
62
  ws.send(JSON.stringify({ type: 'attached', sessionId: msg.sessionId }));
47
63
  console.log(`[termbeam] Client attached to session ${msg.sessionId}`);
@@ -53,7 +69,12 @@ function setupWebSocket(wss, { auth, sessions }) {
53
69
  if (msg.type === 'input') {
54
70
  attached.pty.write(msg.data);
55
71
  } else if (msg.type === 'resize') {
56
- attached.pty.resize(msg.cols, msg.rows);
72
+ const cols = Math.floor(msg.cols);
73
+ const rows = Math.floor(msg.rows);
74
+ if (cols > 0 && cols <= 500 && rows > 0 && rows <= 200) {
75
+ ws._dims = { cols, rows };
76
+ recalcPtySize(attached);
77
+ }
57
78
  }
58
79
  } catch {
59
80
  if (attached) attached.pty.write(raw.toString());
@@ -63,6 +84,7 @@ function setupWebSocket(wss, { auth, sessions }) {
63
84
  ws.on('close', () => {
64
85
  if (attached) {
65
86
  attached.clients.delete(ws);
87
+ recalcPtySize(attached);
66
88
  console.log('[termbeam] Client detached');
67
89
  }
68
90
  });