@mjasano/devtunnel 1.1.0 → 1.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.2.0] - 2025-01-02
9
+
10
+ ### Added
11
+ - Terminal tabs UI for managing multiple sessions
12
+ - tmux session integration (attach to existing tmux sessions)
13
+ - System monitor in sidebar (CPU, Memory, Uptime, Load average)
14
+ - Auto port detection (finds next available port if default is in use)
15
+ - Session detach functionality
16
+
17
+ ### Changed
18
+ - Sessions section moved from sidebar to terminal tabs
19
+ - Improved session switching with proper detach handling
20
+
21
+ ## [1.1.0] - 2025-01-02
22
+
23
+ ### Added
24
+ - Tab-based UI for switching between Terminal and Editor
25
+ - Code editor powered by Monaco Editor (VS Code engine)
26
+ - File explorer in sidebar with breadcrumb navigation
27
+ - File system API endpoints (`/api/files`, `/api/files/read`, `/api/files/write`)
28
+ - Multiple file tabs with unsaved changes indicator
29
+ - Auto-detect syntax highlighting for 20+ languages
30
+ - Keyboard shortcut `Ctrl+S` to save files
31
+
32
+ ### Fixed
33
+ - File click handling with special characters in paths
34
+
35
+ ## [1.0.0] - 2025-01-01
36
+
37
+ ### Added
38
+ - Web-based terminal with xterm.js
39
+ - Persistent sessions (24h timeout)
40
+ - Multiple session management
41
+ - Cloudflare tunnel integration for public URL exposure
42
+ - Session reconnection with output buffer
43
+ - CLI tool (`devtunnel`) with auto-setup
44
+ - Docker support
package/CLAUDE.md ADDED
@@ -0,0 +1,18 @@
1
+ # Project Rules
2
+
3
+ ## Release Process
4
+
5
+ 새 버전 출시 시 아래 순서를 따를 것:
6
+
7
+ 1. `CHANGELOG.md` 업데이트 (변경사항 기록)
8
+ 2. `package.json` 버전 올리기
9
+ 3. `npm publish --access public`
10
+ 4. `git add . && git commit && git push`
11
+ 5. `gh release create vX.X.X` (GitHub Release 생성)
12
+
13
+ ## Version Convention
14
+
15
+ - Semantic Versioning 사용 (MAJOR.MINOR.PATCH)
16
+ - MAJOR: 호환성 깨지는 변경
17
+ - MINOR: 새 기능 추가
18
+ - PATCH: 버그 수정
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasano/devtunnel",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Web terminal with code editor and tunnel manager - access your dev environment from anywhere",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -458,6 +458,78 @@
458
458
  display: none;
459
459
  }
460
460
 
461
+ /* Terminal Tabs */
462
+ .terminal-tabs {
463
+ display: flex;
464
+ background-color: #161b22;
465
+ border-bottom: 1px solid #30363d;
466
+ overflow-x: auto;
467
+ min-height: 35px;
468
+ align-items: center;
469
+ }
470
+
471
+ .terminal-tab {
472
+ display: flex;
473
+ align-items: center;
474
+ gap: 8px;
475
+ padding: 8px 12px;
476
+ font-size: 12px;
477
+ color: #8b949e;
478
+ background-color: #0d1117;
479
+ border-right: 1px solid #30363d;
480
+ cursor: pointer;
481
+ white-space: nowrap;
482
+ }
483
+
484
+ .terminal-tab:hover {
485
+ background-color: #161b22;
486
+ }
487
+
488
+ .terminal-tab.active {
489
+ color: #c9d1d9;
490
+ background-color: #161b22;
491
+ }
492
+
493
+ .terminal-tab-close {
494
+ width: 16px;
495
+ height: 16px;
496
+ display: flex;
497
+ align-items: center;
498
+ justify-content: center;
499
+ border-radius: 4px;
500
+ font-size: 14px;
501
+ line-height: 1;
502
+ }
503
+
504
+ .terminal-tab-close:hover {
505
+ background-color: #30363d;
506
+ }
507
+
508
+ .terminal-tab-new {
509
+ width: 28px;
510
+ height: 28px;
511
+ margin: 0 4px;
512
+ background-color: transparent;
513
+ border: 1px solid #30363d;
514
+ border-radius: 4px;
515
+ color: #8b949e;
516
+ font-size: 16px;
517
+ cursor: pointer;
518
+ display: flex;
519
+ align-items: center;
520
+ justify-content: center;
521
+ }
522
+
523
+ .terminal-tab-new:hover {
524
+ background-color: #21262d;
525
+ color: #c9d1d9;
526
+ }
527
+
528
+ .terminal-tab .tmux-badge {
529
+ color: #3fb950;
530
+ font-size: 10px;
531
+ }
532
+
461
533
  .editor-tabs {
462
534
  display: flex;
463
535
  background-color: #161b22;
@@ -604,6 +676,67 @@
604
676
  color: #484f58;
605
677
  }
606
678
 
679
+ /* System Monitor */
680
+ .system-stats {
681
+ padding: 8px 12px;
682
+ }
683
+
684
+ .stat-row {
685
+ display: flex;
686
+ justify-content: space-between;
687
+ align-items: center;
688
+ padding: 6px 0;
689
+ font-size: 12px;
690
+ border-bottom: 1px solid #21262d;
691
+ }
692
+
693
+ .stat-row:last-child {
694
+ border-bottom: none;
695
+ }
696
+
697
+ .stat-label {
698
+ color: #8b949e;
699
+ }
700
+
701
+ .stat-value {
702
+ color: #c9d1d9;
703
+ font-family: monospace;
704
+ }
705
+
706
+ .stat-bar {
707
+ width: 100%;
708
+ height: 6px;
709
+ background-color: #21262d;
710
+ border-radius: 3px;
711
+ margin-top: 4px;
712
+ overflow: hidden;
713
+ }
714
+
715
+ .stat-bar-fill {
716
+ height: 100%;
717
+ border-radius: 3px;
718
+ transition: width 0.3s ease;
719
+ }
720
+
721
+ .stat-bar-fill.cpu {
722
+ background: linear-gradient(90deg, #58a6ff, #8b5cf6);
723
+ }
724
+
725
+ .stat-bar-fill.memory {
726
+ background: linear-gradient(90deg, #3fb950, #58a6ff);
727
+ }
728
+
729
+ .stat-bar-fill.high {
730
+ background: linear-gradient(90deg, #d29922, #f85149);
731
+ }
732
+
733
+ .system-hostname {
734
+ font-size: 11px;
735
+ color: #6e7681;
736
+ padding: 4px 12px 8px;
737
+ border-bottom: 1px solid #21262d;
738
+ }
739
+
607
740
  @keyframes slideIn {
608
741
  from {
609
742
  transform: translateY(20px);
@@ -665,6 +798,9 @@
665
798
 
666
799
  <div class="main-container">
667
800
  <div id="terminal-container">
801
+ <div class="terminal-tabs" id="terminal-tabs">
802
+ <button class="terminal-tab-new" onclick="createNewSession()" title="New Shell">+</button>
803
+ </div>
668
804
  <div id="terminal"></div>
669
805
  </div>
670
806
 
@@ -681,6 +817,36 @@
681
817
  </div>
682
818
 
683
819
  <div class="sidebar">
820
+ <!-- System Monitor Section -->
821
+ <div class="sidebar-section">
822
+ <div class="sidebar-header" onclick="toggleSection('system')">
823
+ <h2>System</h2>
824
+ </div>
825
+ <div id="system-content">
826
+ <div class="system-hostname" id="system-hostname">Loading...</div>
827
+ <div class="system-stats" id="system-stats">
828
+ <div class="stat-row">
829
+ <span class="stat-label">CPU</span>
830
+ <span class="stat-value" id="cpu-value">--%</span>
831
+ </div>
832
+ <div class="stat-bar"><div class="stat-bar-fill cpu" id="cpu-bar" style="width: 0%"></div></div>
833
+ <div class="stat-row" style="margin-top: 8px;">
834
+ <span class="stat-label">Memory</span>
835
+ <span class="stat-value" id="mem-value">--%</span>
836
+ </div>
837
+ <div class="stat-bar"><div class="stat-bar-fill memory" id="mem-bar" style="width: 0%"></div></div>
838
+ <div class="stat-row" style="margin-top: 8px;">
839
+ <span class="stat-label">Uptime</span>
840
+ <span class="stat-value" id="uptime-value">--</span>
841
+ </div>
842
+ <div class="stat-row">
843
+ <span class="stat-label">Load</span>
844
+ <span class="stat-value" id="load-value">--</span>
845
+ </div>
846
+ </div>
847
+ </div>
848
+ </div>
849
+
684
850
  <!-- Files Section -->
685
851
  <div class="sidebar-section">
686
852
  <div class="sidebar-header" onclick="toggleSection('files')">
@@ -696,20 +862,6 @@
696
862
  </div>
697
863
  </div>
698
864
 
699
- <!-- Sessions Section -->
700
- <div class="sidebar-section">
701
- <div class="sidebar-header" onclick="toggleSection('sessions')">
702
- <h2>Sessions</h2>
703
- <span class="count" id="session-count">0</span>
704
- </div>
705
- <div class="sidebar-content" id="sessions-content">
706
- <button class="btn btn-primary btn-sm" style="width: 100%; margin-bottom: 8px;" onclick="createNewSession()">+ New Session</button>
707
- <div id="session-list">
708
- <div class="empty-state">No sessions</div>
709
- </div>
710
- </div>
711
- </div>
712
-
713
865
  <!-- Tunnels Section -->
714
866
  <div class="sidebar-section">
715
867
  <div class="sidebar-header" onclick="toggleSection('tunnels')">
@@ -759,6 +911,7 @@
759
911
 
760
912
  let tunnels = [];
761
913
  let sessions = [];
914
+ let tmuxSessions = [];
762
915
  let currentSessionId = localStorage.getItem('devtunnel-session-id');
763
916
 
764
917
  // Editor state
@@ -1120,33 +1273,119 @@
1120
1273
  return date.toLocaleTimeString();
1121
1274
  }
1122
1275
 
1123
- function renderSessions() {
1124
- const list = document.getElementById('session-list');
1125
- document.getElementById('session-count').textContent = sessions.length;
1276
+ function formatUptime(seconds) {
1277
+ const days = Math.floor(seconds / 86400);
1278
+ const hours = Math.floor((seconds % 86400) / 3600);
1279
+ const mins = Math.floor((seconds % 3600) / 60);
1280
+ if (days > 0) return `${days}d ${hours}h`;
1281
+ if (hours > 0) return `${hours}h ${mins}m`;
1282
+ return `${mins}m`;
1283
+ }
1126
1284
 
1127
- if (sessions.length === 0) {
1128
- list.innerHTML = '<div class="empty-state">No sessions</div>';
1129
- return;
1285
+ function formatBytes(bytes) {
1286
+ const gb = bytes / (1024 * 1024 * 1024);
1287
+ return gb.toFixed(1) + ' GB';
1288
+ }
1289
+
1290
+ async function updateSystemInfo() {
1291
+ try {
1292
+ const res = await fetch('/api/system');
1293
+ const data = await res.json();
1294
+
1295
+ document.getElementById('system-hostname').textContent =
1296
+ `${data.hostname} (${data.platform}/${data.arch})`;
1297
+
1298
+ // CPU
1299
+ const cpuValue = document.getElementById('cpu-value');
1300
+ const cpuBar = document.getElementById('cpu-bar');
1301
+ cpuValue.textContent = `${data.cpu.usage}%`;
1302
+ cpuBar.style.width = `${data.cpu.usage}%`;
1303
+ cpuBar.className = `stat-bar-fill ${data.cpu.usage > 80 ? 'high' : 'cpu'}`;
1304
+
1305
+ // Memory
1306
+ const memValue = document.getElementById('mem-value');
1307
+ const memBar = document.getElementById('mem-bar');
1308
+ memValue.textContent = `${data.memory.usage}% (${formatBytes(data.memory.used)}/${formatBytes(data.memory.total)})`;
1309
+ memBar.style.width = `${data.memory.usage}%`;
1310
+ memBar.className = `stat-bar-fill ${data.memory.usage > 80 ? 'high' : 'memory'}`;
1311
+
1312
+ // Uptime
1313
+ document.getElementById('uptime-value').textContent = formatUptime(data.uptime);
1314
+
1315
+ // Load average
1316
+ document.getElementById('load-value').textContent =
1317
+ data.loadavg.map(l => l.toFixed(2)).join(' ');
1318
+ } catch (err) {
1319
+ console.error('Failed to fetch system info:', err);
1130
1320
  }
1321
+ }
1131
1322
 
1132
- list.innerHTML = sessions.map(session => `
1133
- <div class="item-card ${session.id === currentSessionId ? 'active' : ''}" onclick="attachToSession('${session.id}')">
1134
- <div class="item-card-header">
1135
- <span class="item-title">
1136
- ${session.id.slice(0, 8)}...
1137
- </span>
1138
- <div style="display: flex; gap: 4px; align-items: center;">
1139
- <span class="item-status ${session.alive ? 'active' : 'stopped'}">${session.alive ? 'alive' : 'ended'}</span>
1140
- <button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); killSession('${session.id}')">x</button>
1141
- </div>
1323
+ // Update system info every 3 seconds
1324
+ updateSystemInfo();
1325
+ setInterval(updateSystemInfo, 3000);
1326
+
1327
+ function renderTerminalTabs() {
1328
+ const container = document.getElementById('terminal-tabs');
1329
+
1330
+ // Build tabs HTML
1331
+ let tabsHtml = '';
1332
+
1333
+ // Tmux sessions first
1334
+ tmuxSessions.forEach(session => {
1335
+ const isActive = currentSessionId && sessionIdDisplay.textContent === `tmux:${session.name}`;
1336
+ tabsHtml += `
1337
+ <div class="terminal-tab ${isActive ? 'active' : ''}" onclick="attachToTmux('${session.name}')">
1338
+ <span class="tmux-badge">tmux</span>
1339
+ <span>${session.name}</span>
1142
1340
  </div>
1143
- <div class="item-meta">
1144
- ${session.clients} client(s) connected | Last: ${formatTime(session.lastAccess)}
1341
+ `;
1342
+ });
1343
+
1344
+ // Shell sessions
1345
+ sessions.forEach(session => {
1346
+ const isActive = session.id === currentSessionId;
1347
+ tabsHtml += `
1348
+ <div class="terminal-tab ${isActive ? 'active' : ''}" onclick="attachToSession('${session.id}')">
1349
+ <span>${session.id.slice(0, 8)}</span>
1350
+ <span class="terminal-tab-close" onclick="event.stopPropagation(); killSession('${session.id}')">×</span>
1145
1351
  </div>
1146
- </div>
1147
- `).join('');
1352
+ `;
1353
+ });
1354
+
1355
+ // Add new button at the end
1356
+ tabsHtml += `<button class="terminal-tab-new" onclick="createNewSession()" title="New Shell">+</button>`;
1357
+ tabsHtml += `<button class="terminal-tab-new" onclick="refreshTmuxSessions()" title="Refresh tmux" style="font-size: 12px;">↻</button>`;
1358
+
1359
+ container.innerHTML = tabsHtml;
1148
1360
  }
1149
1361
 
1362
+ function renderSessions() {
1363
+ renderTerminalTabs();
1364
+ }
1365
+
1366
+ function renderTmuxSessions() {
1367
+ renderTerminalTabs();
1368
+ }
1369
+
1370
+ function attachToTmux(sessionName) {
1371
+ // Detach from current session first
1372
+ if (currentSessionId) {
1373
+ ws.send(JSON.stringify({ type: 'detach' }));
1374
+ }
1375
+ currentSessionId = null;
1376
+ localStorage.removeItem('devtunnel-session-id');
1377
+ term.clear();
1378
+ ws.send(JSON.stringify({ type: 'attach-tmux', tmuxSession: sessionName }));
1379
+ switchTab('terminal');
1380
+ }
1381
+
1382
+ function refreshTmuxSessions() {
1383
+ ws.send(JSON.stringify({ type: 'refresh-tmux' }));
1384
+ }
1385
+
1386
+ window.attachToTmux = attachToTmux;
1387
+ window.refreshTmuxSessions = refreshTmuxSessions;
1388
+
1150
1389
  function renderTunnels() {
1151
1390
  const list = document.getElementById('tunnel-list');
1152
1391
  document.getElementById('tunnel-count').textContent = tunnels.length;
@@ -1205,18 +1444,28 @@
1205
1444
  }
1206
1445
 
1207
1446
  function createNewSession() {
1447
+ // Detach from current session first
1448
+ if (currentSessionId) {
1449
+ ws.send(JSON.stringify({ type: 'detach' }));
1450
+ }
1208
1451
  currentSessionId = null;
1209
1452
  localStorage.removeItem('devtunnel-session-id');
1210
1453
  term.clear();
1211
1454
  ws.send(JSON.stringify({ type: 'attach', sessionId: null }));
1455
+ switchTab('terminal');
1212
1456
  }
1213
1457
 
1214
1458
  function attachToSession(sessionId) {
1215
1459
  if (sessionId === currentSessionId) return;
1460
+ // Detach from current session first
1461
+ if (currentSessionId) {
1462
+ ws.send(JSON.stringify({ type: 'detach' }));
1463
+ }
1216
1464
  currentSessionId = sessionId;
1217
1465
  localStorage.setItem('devtunnel-session-id', sessionId);
1218
1466
  term.clear();
1219
1467
  ws.send(JSON.stringify({ type: 'attach', sessionId }));
1468
+ switchTab('terminal');
1220
1469
  }
1221
1470
 
1222
1471
  function killSession(sessionId) {
@@ -1245,8 +1494,8 @@
1245
1494
  statusText.textContent = 'Connected';
1246
1495
  reconnectOverlay.classList.remove('show');
1247
1496
 
1248
- // Attach to existing or new session
1249
- ws.send(JSON.stringify({ type: 'attach', sessionId: currentSessionId }));
1497
+ // Don't auto-attach - let user choose session manually
1498
+ term.write('\x1b[90mClick + to create a new session.\x1b[0m\r\n');
1250
1499
  };
1251
1500
 
1252
1501
  ws.onmessage = (event) => {
@@ -1258,7 +1507,7 @@
1258
1507
  currentSessionId = msg.sessionId;
1259
1508
  localStorage.setItem('devtunnel-session-id', msg.sessionId);
1260
1509
  sessionIndicator.style.display = 'flex';
1261
- sessionIdDisplay.textContent = msg.sessionId.slice(0, 8);
1510
+ sessionIdDisplay.textContent = msg.tmuxSession ? `tmux:${msg.tmuxSession}` : msg.sessionId.slice(0, 8);
1262
1511
 
1263
1512
  // Send initial size
1264
1513
  ws.send(JSON.stringify({
@@ -1267,7 +1516,10 @@
1267
1516
  rows: term.rows
1268
1517
  }));
1269
1518
 
1270
- showToast('Session attached');
1519
+ // Update terminal tabs to show active state
1520
+ renderTerminalTabs();
1521
+
1522
+ showToast(msg.tmuxSession ? `Attached to tmux: ${msg.tmuxSession}` : 'Session attached');
1271
1523
  break;
1272
1524
 
1273
1525
  case 'output':
@@ -1288,6 +1540,11 @@
1288
1540
  renderTunnels();
1289
1541
  break;
1290
1542
 
1543
+ case 'tmux-sessions':
1544
+ tmuxSessions = msg.data;
1545
+ renderTmuxSessions();
1546
+ break;
1547
+
1291
1548
  case 'tunnel-created':
1292
1549
  showToast(`Tunnel created for port ${msg.data.port}`);
1293
1550
  createTunnelBtn.disabled = false;
package/server.js CHANGED
@@ -30,6 +30,98 @@ function generateSessionId() {
30
30
  return crypto.randomBytes(16).toString('hex');
31
31
  }
32
32
 
33
+ // Get list of tmux sessions
34
+ async function getTmuxSessions() {
35
+ return new Promise((resolve) => {
36
+ const tmux = spawn('tmux', ['list-sessions', '-F', '#{session_name}:#{session_windows}:#{session_attached}']);
37
+ let output = '';
38
+
39
+ tmux.stdout.on('data', (data) => {
40
+ output += data.toString();
41
+ });
42
+
43
+ tmux.on('close', (code) => {
44
+ if (code !== 0 || !output.trim()) {
45
+ resolve([]);
46
+ return;
47
+ }
48
+
49
+ const sessions = output.trim().split('\n').map(line => {
50
+ const [name, windows, attached] = line.split(':');
51
+ return {
52
+ name,
53
+ windows: parseInt(windows) || 1,
54
+ attached: attached === '1'
55
+ };
56
+ });
57
+
58
+ resolve(sessions);
59
+ });
60
+
61
+ tmux.on('error', () => {
62
+ resolve([]);
63
+ });
64
+ });
65
+ }
66
+
67
+ // Create session attached to tmux
68
+ function createTmuxSession(tmuxSessionName) {
69
+ const id = `tmux-${tmuxSessionName}-${Date.now()}`;
70
+
71
+ const ptyProcess = pty.spawn('tmux', ['attach-session', '-t', tmuxSessionName], {
72
+ name: 'xterm-256color',
73
+ cols: 80,
74
+ rows: 24,
75
+ cwd: process.env.WORKSPACE || os.homedir(),
76
+ env: { ...process.env, TERM: 'xterm-256color' }
77
+ });
78
+
79
+ const session = {
80
+ id,
81
+ pty: ptyProcess,
82
+ outputBuffer: '',
83
+ clients: new Set(),
84
+ lastAccess: Date.now(),
85
+ createdAt: Date.now(),
86
+ alive: true,
87
+ type: 'tmux',
88
+ tmuxSession: tmuxSessionName
89
+ };
90
+
91
+ ptyProcess.onData((data) => {
92
+ session.outputBuffer += data;
93
+ if (session.outputBuffer.length > OUTPUT_BUFFER_SIZE) {
94
+ session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_SIZE);
95
+ }
96
+
97
+ session.clients.forEach(ws => {
98
+ if (ws.readyState === WebSocket.OPEN) {
99
+ ws.send(JSON.stringify({ type: 'output', data }));
100
+ }
101
+ });
102
+ });
103
+
104
+ ptyProcess.onExit(({ exitCode, signal }) => {
105
+ console.log(`Tmux session ${tmuxSessionName} detached with code ${exitCode}`);
106
+ session.alive = false;
107
+
108
+ session.clients.forEach(ws => {
109
+ if (ws.readyState === WebSocket.OPEN) {
110
+ ws.send(JSON.stringify({ type: 'exit', exitCode, signal }));
111
+ }
112
+ });
113
+
114
+ setTimeout(() => {
115
+ sessions.delete(id);
116
+ broadcastSessionList();
117
+ }, 5000);
118
+ });
119
+
120
+ sessions.set(id, session);
121
+ console.log(`Tmux session attached: ${tmuxSessionName}`);
122
+ return session;
123
+ }
124
+
33
125
  // Create or get existing session
34
126
  function getOrCreateSession(sessionId = null) {
35
127
  // If sessionId provided and exists, return it
@@ -120,7 +212,9 @@ function broadcastSessionList() {
120
212
  createdAt: s.createdAt,
121
213
  lastAccess: s.lastAccess,
122
214
  alive: s.alive,
123
- clients: s.clients.size
215
+ clients: s.clients.size,
216
+ type: s.type || 'shell',
217
+ tmuxSession: s.tmuxSession
124
218
  }));
125
219
 
126
220
  wss.clients.forEach(client => {
@@ -234,7 +328,7 @@ function stopTunnel(id) {
234
328
  }
235
329
 
236
330
  // WebSocket handling
237
- wss.on('connection', (ws) => {
331
+ wss.on('connection', async (ws) => {
238
332
  console.log('Client connected');
239
333
 
240
334
  let currentSession = null;
@@ -254,16 +348,38 @@ wss.on('connection', (ws) => {
254
348
  createdAt: s.createdAt,
255
349
  lastAccess: s.lastAccess,
256
350
  alive: s.alive,
257
- clients: s.clients.size
351
+ clients: s.clients.size,
352
+ type: s.type || 'shell',
353
+ tmuxSession: s.tmuxSession
258
354
  }));
259
355
  ws.send(JSON.stringify({ type: 'sessions', data: sessionList }));
260
356
 
357
+ // Send tmux sessions list
358
+ const tmuxSessions = await getTmuxSessions();
359
+ ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
360
+
261
361
  ws.on('message', (message) => {
262
362
  try {
263
363
  const msg = JSON.parse(message.toString());
264
364
 
265
365
  switch (msg.type) {
366
+ case 'detach':
367
+ // Detach from current session
368
+ if (currentSession) {
369
+ currentSession.clients.delete(ws);
370
+ currentSession.lastAccess = Date.now();
371
+ broadcastSessionList();
372
+ console.log(`Client detached from session ${currentSession.id}`);
373
+ currentSession = null;
374
+ }
375
+ break;
376
+
266
377
  case 'attach':
378
+ // Detach from current session first if any
379
+ if (currentSession) {
380
+ currentSession.clients.delete(ws);
381
+ currentSession.lastAccess = Date.now();
382
+ }
267
383
  // Attach to existing or new session
268
384
  try {
269
385
  currentSession = getOrCreateSession(msg.sessionId);
@@ -339,6 +455,43 @@ wss.on('connection', (ws) => {
339
455
  }
340
456
  break;
341
457
 
458
+ case 'attach-tmux':
459
+ if (msg.tmuxSession) {
460
+ try {
461
+ // Detach from current session if any
462
+ if (currentSession) {
463
+ currentSession.clients.delete(ws);
464
+ currentSession.lastAccess = Date.now();
465
+ broadcastSessionList();
466
+ }
467
+
468
+ currentSession = createTmuxSession(msg.tmuxSession);
469
+ currentSession.clients.add(ws);
470
+
471
+ ws.send(JSON.stringify({
472
+ type: 'attached',
473
+ sessionId: currentSession.id,
474
+ alive: currentSession.alive,
475
+ tmuxSession: msg.tmuxSession
476
+ }));
477
+
478
+ broadcastSessionList();
479
+ console.log(`Client attached to tmux session: ${msg.tmuxSession}`);
480
+ } catch (err) {
481
+ ws.send(JSON.stringify({
482
+ type: 'error',
483
+ message: err.message
484
+ }));
485
+ }
486
+ }
487
+ break;
488
+
489
+ case 'refresh-tmux':
490
+ getTmuxSessions().then(tmuxSessions => {
491
+ ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
492
+ });
493
+ break;
494
+
342
495
  default:
343
496
  console.log('Unknown message type:', msg.type);
344
497
  }
@@ -365,6 +518,119 @@ wss.on('connection', (ws) => {
365
518
  });
366
519
  });
367
520
 
521
+ // System Monitoring API
522
+ async function getMemoryInfo() {
523
+ const totalMem = os.totalmem();
524
+
525
+ // macOS: use vm_stat for accurate memory info
526
+ if (os.platform() === 'darwin') {
527
+ return new Promise((resolve) => {
528
+ const vmstat = spawn('vm_stat');
529
+ let output = '';
530
+
531
+ vmstat.stdout.on('data', (data) => {
532
+ output += data.toString();
533
+ });
534
+
535
+ vmstat.on('close', () => {
536
+ try {
537
+ const pageSize = 16384; // macOS default page size (16KB on Apple Silicon)
538
+ const lines = output.split('\n');
539
+
540
+ let free = 0, active = 0, inactive = 0, wired = 0, compressed = 0, purgeable = 0;
541
+
542
+ lines.forEach(line => {
543
+ const match = line.match(/^(.+):\s+(\d+)/);
544
+ if (match) {
545
+ const value = parseInt(match[2]) * pageSize;
546
+ const key = match[1].toLowerCase();
547
+ if (key.includes('free')) free = value;
548
+ else if (key.includes('active')) active = value;
549
+ else if (key.includes('inactive')) inactive = value;
550
+ else if (key.includes('wired')) wired = value;
551
+ else if (key.includes('compressed')) compressed = value;
552
+ else if (key.includes('purgeable')) purgeable = value;
553
+ }
554
+ });
555
+
556
+ // App Memory = Active + Wired + Compressed (what Activity Monitor shows)
557
+ const appMemory = active + wired + compressed;
558
+ const usage = Math.round((appMemory / totalMem) * 100);
559
+
560
+ resolve({
561
+ total: totalMem,
562
+ used: appMemory,
563
+ free: totalMem - appMemory,
564
+ usage
565
+ });
566
+ } catch {
567
+ // Fallback to os.freemem()
568
+ const freeMem = os.freemem();
569
+ resolve({
570
+ total: totalMem,
571
+ used: totalMem - freeMem,
572
+ free: freeMem,
573
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
574
+ });
575
+ }
576
+ });
577
+
578
+ vmstat.on('error', () => {
579
+ const freeMem = os.freemem();
580
+ resolve({
581
+ total: totalMem,
582
+ used: totalMem - freeMem,
583
+ free: freeMem,
584
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
585
+ });
586
+ });
587
+ });
588
+ }
589
+
590
+ // Other platforms: use os.freemem()
591
+ const freeMem = os.freemem();
592
+ return {
593
+ total: totalMem,
594
+ used: totalMem - freeMem,
595
+ free: freeMem,
596
+ usage: Math.round(((totalMem - freeMem) / totalMem) * 100)
597
+ };
598
+ }
599
+
600
+ async function getSystemInfo() {
601
+ const cpus = os.cpus();
602
+ const memory = await getMemoryInfo();
603
+
604
+ // Calculate CPU usage
605
+ let totalIdle = 0;
606
+ let totalTick = 0;
607
+ cpus.forEach(cpu => {
608
+ for (const type in cpu.times) {
609
+ totalTick += cpu.times[type];
610
+ }
611
+ totalIdle += cpu.times.idle;
612
+ });
613
+ const cpuUsage = Math.round((1 - totalIdle / totalTick) * 100);
614
+
615
+ return {
616
+ hostname: os.hostname(),
617
+ platform: os.platform(),
618
+ arch: os.arch(),
619
+ uptime: os.uptime(),
620
+ cpu: {
621
+ model: cpus[0]?.model || 'Unknown',
622
+ cores: cpus.length,
623
+ usage: cpuUsage
624
+ },
625
+ memory,
626
+ loadavg: os.loadavg()
627
+ };
628
+ }
629
+
630
+ app.get('/api/system', async (req, res) => {
631
+ res.json(await getSystemInfo());
632
+ });
633
+
368
634
  // File System API
369
635
  const WORKSPACE_ROOT = process.env.WORKSPACE || os.homedir();
370
636
 
@@ -569,9 +835,51 @@ app.get('/health', (req, res) => {
569
835
  });
570
836
  });
571
837
 
572
- const PORT = process.env.PORT || 3000;
573
- server.listen(PORT, '0.0.0.0', () => {
574
- console.log(`DevTunnel server running on http://localhost:${PORT}`);
838
+ const DEFAULT_PORT = process.env.PORT || 3000;
839
+
840
+ function findAvailablePort(startPort, maxAttempts = 10) {
841
+ return new Promise((resolve, reject) => {
842
+ let port = startPort;
843
+ let attempts = 0;
844
+
845
+ const tryPort = () => {
846
+ if (attempts >= maxAttempts) {
847
+ reject(new Error(`No available port found after ${maxAttempts} attempts`));
848
+ return;
849
+ }
850
+
851
+ const testServer = require('net').createServer();
852
+ testServer.once('error', (err) => {
853
+ if (err.code === 'EADDRINUSE') {
854
+ console.log(`Port ${port} is in use, trying ${port + 1}...`);
855
+ port++;
856
+ attempts++;
857
+ tryPort();
858
+ } else {
859
+ reject(err);
860
+ }
861
+ });
862
+
863
+ testServer.once('listening', () => {
864
+ testServer.close(() => {
865
+ resolve(port);
866
+ });
867
+ });
868
+
869
+ testServer.listen(port, '0.0.0.0');
870
+ };
871
+
872
+ tryPort();
873
+ });
874
+ }
875
+
876
+ findAvailablePort(DEFAULT_PORT).then((PORT) => {
877
+ server.listen(PORT, '0.0.0.0', () => {
878
+ console.log(`DevTunnel server running on http://localhost:${PORT}`);
879
+ });
880
+ }).catch((err) => {
881
+ console.error('Failed to start server:', err.message);
882
+ process.exit(1);
575
883
  });
576
884
 
577
885
  // Graceful shutdown