termbeam 1.9.0 → 1.10.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
@@ -72,6 +72,7 @@ termbeam -i # interactive setup wizard
72
72
 
73
73
  - **Terminal search** with regex, match count, and prev/next navigation
74
74
  - **Command palette** (Ctrl+K / Cmd+K) for quick access to all actions
75
+ - **File upload** — send files from your phone to the session's working directory
75
76
  - **Completion notifications** — browser alerts when background commands finish
76
77
  - **12 color themes** with adjustable font size
77
78
  - **Port preview** — reverse-proxy a local web server through TermBeam
@@ -108,6 +109,7 @@ flowchart LR
108
109
  | `--port <port>` | Server port | `3456` |
109
110
  | `--host <addr>` | Bind address | `127.0.0.1` |
110
111
  | `--lan` | Bind to all interfaces (LAN access) | Off |
112
+ | `--public` | Allow public tunnel access (no Microsoft login) | Off |
111
113
  | `-i, --interactive` | Interactive setup wizard | Off |
112
114
  | `--log-level <level>` | Log verbosity (error/warn/info/debug) | `info` |
113
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.9.0",
3
+ "version": "1.10.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": {
package/public/index.html CHANGED
@@ -991,17 +991,22 @@
991
991
  }
992
992
 
993
993
  async function loadSessions() {
994
- const res = await fetch('/api/sessions');
995
- const sessions = await res.json();
994
+ try {
995
+ const res = await fetch('/api/sessions');
996
+ if (!res.ok) {
997
+ console.error(`Failed to load sessions: ${res.status}`);
998
+ return;
999
+ }
1000
+ const sessions = await res.json();
996
1001
 
997
- if (sessions.length === 0) {
998
- listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
999
- return;
1000
- }
1002
+ if (sessions.length === 0) {
1003
+ listEl.innerHTML = '<div class="empty-state">No active sessions</div>';
1004
+ return;
1005
+ }
1001
1006
 
1002
- listEl.innerHTML = sessions
1003
- .map(
1004
- (s) => `
1007
+ listEl.innerHTML = sessions
1008
+ .map(
1009
+ (s) => `
1005
1010
  <div class="swipe-wrap" data-session-id="${esc(s.id)}">
1006
1011
  <div class="swipe-delete">
1007
1012
  <button data-delete-id="${esc(s.id)}"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg><span>Delete</span></button>
@@ -1031,22 +1036,25 @@
1031
1036
  </div>
1032
1037
  </div>
1033
1038
  `,
1034
- )
1035
- .join('');
1039
+ )
1040
+ .join('');
1036
1041
 
1037
- // Attach swipe handlers and click handlers after rendering
1038
- listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
1039
- listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
1040
- btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
1041
- });
1042
- listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
1043
- card.addEventListener('click', () => {
1044
- location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
1042
+ // Attach swipe handlers and click handlers after rendering
1043
+ listEl.querySelectorAll('.swipe-wrap').forEach(initSwipe);
1044
+ listEl.querySelectorAll('[data-delete-id]').forEach((btn) => {
1045
+ btn.addEventListener('click', (e) => deleteSession(btn.dataset.deleteId, e));
1045
1046
  });
1046
- });
1047
- listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
1048
- dot.style.background = dot.dataset.color || 'var(--success)';
1049
- });
1047
+ listEl.querySelectorAll('[data-nav-id]').forEach((card) => {
1048
+ card.addEventListener('click', () => {
1049
+ location.href = '/terminal?id=' + encodeURIComponent(card.dataset.navId);
1050
+ });
1051
+ });
1052
+ listEl.querySelectorAll('.dot[data-color]').forEach((dot) => {
1053
+ dot.style.background = dot.dataset.color || 'var(--success)';
1054
+ });
1055
+ } catch (err) {
1056
+ console.error('Failed to load sessions:', err);
1057
+ }
1050
1058
  }
1051
1059
 
1052
1060
  document.getElementById('new-session-btn').addEventListener('click', () => {
@@ -1085,13 +1093,21 @@
1085
1093
  if (initialCommand) body.initialCommand = initialCommand;
1086
1094
  if (color) body.color = color;
1087
1095
 
1088
- const res = await fetch('/api/sessions', {
1089
- method: 'POST',
1090
- headers: { 'Content-Type': 'application/json' },
1091
- body: JSON.stringify(body),
1092
- });
1093
- const data = await res.json();
1094
- location.href = data.url;
1096
+ try {
1097
+ const res = await fetch('/api/sessions', {
1098
+ method: 'POST',
1099
+ headers: { 'Content-Type': 'application/json' },
1100
+ body: JSON.stringify(body),
1101
+ });
1102
+ if (!res.ok) {
1103
+ console.error(`Failed to create session: ${res.status}`);
1104
+ return;
1105
+ }
1106
+ const data = await res.json();
1107
+ location.href = data.url;
1108
+ } catch (err) {
1109
+ console.error('Failed to create session:', err);
1110
+ }
1095
1111
  });
1096
1112
 
1097
1113
  // --- Shell detection ---
@@ -1101,6 +1117,7 @@
1101
1117
  const shellSelect = document.getElementById('sess-shell');
1102
1118
  try {
1103
1119
  const res = await fetch('/api/shells');
1120
+ if (!res.ok) throw new Error(`Failed to load shells: ${res.status}`);
1104
1121
  const data = await res.json();
1105
1122
  if (data.cwd) {
1106
1123
  document.getElementById('sess-cwd').placeholder = data.cwd;
@@ -1225,7 +1242,10 @@
1225
1242
  document.getElementById('browse-btn').addEventListener('click', async () => {
1226
1243
  if (hubServerCwd === '/') {
1227
1244
  try {
1228
- const data = await fetch('/api/shells').then((r) => r.json());
1245
+ const data = await fetch('/api/shells').then((r) => {
1246
+ if (!r.ok) throw new Error(`${r.status}`);
1247
+ return r.json();
1248
+ });
1229
1249
  if (data.cwd) hubServerCwd = data.cwd;
1230
1250
  } catch {}
1231
1251
  }
@@ -1254,6 +1274,7 @@
1254
1274
 
1255
1275
  try {
1256
1276
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
1277
+ if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
1257
1278
  const data = await res.json();
1258
1279
  let items = '';
1259
1280
  // Add parent (..) entry unless at root
@@ -1310,7 +1331,10 @@
1310
1331
 
1311
1332
  // Fetch version
1312
1333
  fetch('/api/version')
1313
- .then((r) => r.json())
1334
+ .then((r) => {
1335
+ if (!r.ok) throw new Error(`${r.status}`);
1336
+ return r.json();
1337
+ })
1314
1338
  .then((d) => {
1315
1339
  document.getElementById('version').textContent = 'v' + d.version;
1316
1340
  })
@@ -1165,6 +1165,9 @@
1165
1165
  max-height: 90vh;
1166
1166
  overflow-y: auto;
1167
1167
  }
1168
+ #upload-modal .modal {
1169
+ overflow: visible;
1170
+ }
1168
1171
  .modal h2 {
1169
1172
  font-size: 18px;
1170
1173
  margin-bottom: 16px;
@@ -1982,6 +1985,53 @@
1982
1985
  <button class="key-btn icon-btn" data-key="&#x1b;[C" title="Right">→</button>
1983
1986
  </div>
1984
1987
  </div>
1988
+ <input type="file" id="upload-input" multiple hidden />
1989
+
1990
+ <!-- Upload Confirm Modal -->
1991
+ <div class="modal-overlay" id="upload-modal">
1992
+ <div class="modal">
1993
+ <h2>Upload Files</h2>
1994
+ <div
1995
+ id="upload-file-list"
1996
+ style="
1997
+ margin-bottom: 12px;
1998
+ font-size: 13px;
1999
+ color: var(--text-secondary);
2000
+ max-height: 120px;
2001
+ overflow-y: auto;
2002
+ "
2003
+ ></div>
2004
+ <label for="upload-dir">Destination directory</label>
2005
+ <div class="cwd-picker">
2006
+ <input type="text" id="upload-dir" placeholder="Session working directory" />
2007
+ <button
2008
+ type="button"
2009
+ class="cwd-browse-btn"
2010
+ id="upload-browse-btn"
2011
+ title="Browse folders"
2012
+ >
2013
+ <svg
2014
+ width="18"
2015
+ height="18"
2016
+ viewBox="0 0 24 24"
2017
+ fill="none"
2018
+ stroke="currentColor"
2019
+ stroke-width="2"
2020
+ stroke-linecap="round"
2021
+ stroke-linejoin="round"
2022
+ >
2023
+ <path
2024
+ d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"
2025
+ />
2026
+ </svg>
2027
+ </button>
2028
+ </div>
2029
+ <div class="modal-actions">
2030
+ <button class="btn-cancel" id="upload-cancel">Cancel</button>
2031
+ <button class="btn-create" id="upload-confirm">Upload</button>
2032
+ </div>
2033
+ </div>
2034
+ </div>
1985
2035
 
1986
2036
  <div id="reconnect-overlay">
1987
2037
  <div class="msg">Session disconnected</div>
@@ -2426,7 +2476,14 @@
2426
2476
 
2427
2477
  // ===== Init =====
2428
2478
  async function init() {
2429
- const sessionList = await fetch('/api/sessions').then((r) => r.json());
2479
+ let sessionList = [];
2480
+ try {
2481
+ const res = await fetch('/api/sessions');
2482
+ if (!res.ok) throw new Error(`${res.status}`);
2483
+ sessionList = await res.json();
2484
+ } catch (err) {
2485
+ console.error('Failed to load sessions:', err);
2486
+ }
2430
2487
  const initialId = new URLSearchParams(location.search).get('id');
2431
2488
 
2432
2489
  for (const s of sessionList) addSession(s);
@@ -2449,6 +2506,7 @@
2449
2506
  setupKeyBar();
2450
2507
  setupPaste();
2451
2508
  setupImagePaste();
2509
+ setupUpload();
2452
2510
  setupSelectMode();
2453
2511
  setupNewSessionModal();
2454
2512
  setupPreviewModal();
@@ -2627,7 +2685,10 @@
2627
2685
 
2628
2686
  // Version
2629
2687
  fetch('/api/version')
2630
- .then((r) => r.json())
2688
+ .then((r) => {
2689
+ if (!r.ok) throw new Error(`${r.status}`);
2690
+ return r.json();
2691
+ })
2631
2692
  .then((d) => {
2632
2693
  window._termbeamVersion = 'v' + d.version;
2633
2694
  document.getElementById('side-panel-version').textContent = 'v' + d.version;
@@ -3739,6 +3800,168 @@
3739
3800
  );
3740
3801
  }
3741
3802
 
3803
+ // ===== File Upload =====
3804
+ function setupUpload() {
3805
+ const uploadInput = document.getElementById('upload-input');
3806
+ const uploadModal = document.getElementById('upload-modal');
3807
+ const uploadDirInput = document.getElementById('upload-dir');
3808
+ const uploadFileList = document.getElementById('upload-file-list');
3809
+ const uploadConfirmBtn = document.getElementById('upload-confirm');
3810
+ const uploadCancelBtn = document.getElementById('upload-cancel');
3811
+ const uploadBrowseBtn = document.getElementById('upload-browse-btn');
3812
+
3813
+ let pendingFiles = null;
3814
+
3815
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
3816
+
3817
+ function openUploadModal(files) {
3818
+ pendingFiles = files;
3819
+ const ms = managed.get(activeId);
3820
+ const cwd = (ms && ms.cwd) || '';
3821
+ uploadDirInput.value = cwd;
3822
+ let hasOversized = false;
3823
+ uploadFileList.innerHTML = '';
3824
+ Array.from(files).forEach((f) => {
3825
+ const oversized = f.size > MAX_FILE_SIZE;
3826
+ if (oversized) hasOversized = true;
3827
+ const row = document.createElement('div');
3828
+ if (oversized) row.style.color = '#f87171';
3829
+ row.textContent =
3830
+ '📄 ' +
3831
+ f.name +
3832
+ ' (' +
3833
+ formatSize(f.size) +
3834
+ ')' +
3835
+ (oversized ? ' exceeds 10 MB limit' : '');
3836
+ uploadFileList.appendChild(row);
3837
+ });
3838
+ uploadConfirmBtn.disabled = hasOversized;
3839
+ uploadConfirmBtn.style.opacity = hasOversized ? '0.5' : '1';
3840
+ uploadModal.classList.add('visible');
3841
+ }
3842
+
3843
+ function closeUploadModal() {
3844
+ uploadModal.classList.remove('visible');
3845
+ closeBrowseDropdown();
3846
+ pendingFiles = null;
3847
+ }
3848
+
3849
+ function formatSize(bytes) {
3850
+ if (bytes < 1024) return bytes + ' B';
3851
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
3852
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
3853
+ }
3854
+
3855
+ uploadInput.addEventListener('change', () => {
3856
+ const files = uploadInput.files;
3857
+ if (!files || !files.length || !activeId) return;
3858
+ openUploadModal(files);
3859
+ });
3860
+
3861
+ uploadCancelBtn.addEventListener('click', closeUploadModal);
3862
+ uploadModal.addEventListener('click', (e) => {
3863
+ if (e.target === uploadModal) closeUploadModal();
3864
+ });
3865
+
3866
+ // Directory browsing (reuse /api/dirs)
3867
+ let browseDropdown = null;
3868
+
3869
+ function closeBrowseDropdown() {
3870
+ if (browseDropdown) {
3871
+ browseDropdown.remove();
3872
+ browseDropdown = null;
3873
+ }
3874
+ }
3875
+
3876
+ // Close dropdown when clicking outside
3877
+ document.addEventListener('click', (e) => {
3878
+ if (
3879
+ browseDropdown &&
3880
+ !browseDropdown.contains(e.target) &&
3881
+ e.target !== uploadBrowseBtn
3882
+ ) {
3883
+ closeBrowseDropdown();
3884
+ }
3885
+ });
3886
+
3887
+ uploadBrowseBtn.addEventListener('click', async () => {
3888
+ if (browseDropdown) {
3889
+ closeBrowseDropdown();
3890
+ return;
3891
+ }
3892
+ const q = uploadDirInput.value || '';
3893
+ try {
3894
+ const res = await fetch('/api/dirs?q=' + encodeURIComponent(q ? q + '/' : ''), {
3895
+ credentials: 'same-origin',
3896
+ });
3897
+ if (!res.ok) throw new Error(`Failed to browse directories: ${res.status}`);
3898
+ const data = await res.json();
3899
+ if (!data.dirs || !data.dirs.length) return;
3900
+ browseDropdown = document.createElement('div');
3901
+ browseDropdown.style.cssText =
3902
+ 'position:absolute;left:0;right:0;bottom:100%;margin-bottom:4px;max-height:150px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:6px;z-index:10;box-shadow:0 -4px 12px rgba(0,0,0,0.2);';
3903
+ data.dirs.forEach((d) => {
3904
+ const opt = document.createElement('div');
3905
+ opt.textContent = d;
3906
+ opt.style.cssText =
3907
+ 'padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);';
3908
+ opt.addEventListener('mouseenter', () => (opt.style.background = 'var(--hover)'));
3909
+ opt.addEventListener('mouseleave', () => (opt.style.background = 'none'));
3910
+ opt.addEventListener('click', () => {
3911
+ uploadDirInput.value = d;
3912
+ closeBrowseDropdown();
3913
+ });
3914
+ browseDropdown.appendChild(opt);
3915
+ });
3916
+ uploadBrowseBtn.parentElement.style.position = 'relative';
3917
+ uploadBrowseBtn.parentElement.appendChild(browseDropdown);
3918
+ } catch {}
3919
+ });
3920
+
3921
+ uploadConfirmBtn.addEventListener('click', async () => {
3922
+ if (!pendingFiles || !pendingFiles.length || !activeId) return;
3923
+ const targetDir = uploadDirInput.value.trim();
3924
+ const filesToUpload = Array.from(pendingFiles);
3925
+ closeUploadModal();
3926
+
3927
+ let uploaded = 0;
3928
+ let failed = 0;
3929
+
3930
+ for (const file of filesToUpload) {
3931
+ try {
3932
+ const headers = {
3933
+ 'Content-Type': file.type || 'application/octet-stream',
3934
+ 'X-Filename': file.name,
3935
+ };
3936
+ if (targetDir) headers['X-Target-Dir'] = targetDir;
3937
+ const res = await fetch(`/api/sessions/${activeId}/upload`, {
3938
+ method: 'POST',
3939
+ headers,
3940
+ body: file,
3941
+ credentials: 'same-origin',
3942
+ });
3943
+ if (!res.ok) {
3944
+ const err = await res.json().catch(() => ({}));
3945
+ throw new Error(err.error || 'Upload failed');
3946
+ }
3947
+ await res.json();
3948
+ uploaded++;
3949
+ } catch (err) {
3950
+ failed++;
3951
+ console.error('Upload error:', file.name, err);
3952
+ }
3953
+ }
3954
+
3955
+ if (uploaded > 0) {
3956
+ const dir = targetDir || 'session directory';
3957
+ showToast(`${uploaded} file${uploaded > 1 ? 's' : ''} uploaded to ${dir}`);
3958
+ }
3959
+ if (failed > 0) {
3960
+ showToast(`${failed} file${failed > 1 ? 's' : ''} failed to upload`);
3961
+ }
3962
+ });
3963
+ }
3964
+
3742
3965
  // ===== New Session Modal =====
3743
3966
  let shellsLoaded = false;
3744
3967
 
@@ -3785,7 +4008,10 @@
3785
4008
  document.getElementById('ns-browse-btn').addEventListener('click', async () => {
3786
4009
  if (serverCwd === '/') {
3787
4010
  try {
3788
- const data = await fetch('/api/shells').then((r) => r.json());
4011
+ const data = await fetch('/api/shells').then((r) => {
4012
+ if (!r.ok) throw new Error(`${r.status}`);
4013
+ return r.json();
4014
+ });
3789
4015
  if (data.cwd) serverCwd = data.cwd;
3790
4016
  } catch {}
3791
4017
  }
@@ -3813,6 +4039,7 @@
3813
4039
  nsBrowserList.innerHTML = '<div class="browser-empty">Loading…</div>';
3814
4040
  try {
3815
4041
  const res = await fetch(`/api/dirs?q=${encodeURIComponent(dir + '/')}`);
4042
+ if (!res.ok) throw new Error(`Failed to load directories: ${res.status}`);
3816
4043
  const data = await res.json();
3817
4044
  let items = '';
3818
4045
  // Add parent (..) entry unless at root
@@ -3870,7 +4097,10 @@
3870
4097
  if (shellsLoaded) return;
3871
4098
  const sel = document.getElementById('ns-shell');
3872
4099
  try {
3873
- const data = await fetch('/api/shells').then((r) => r.json());
4100
+ const data = await fetch('/api/shells').then((r) => {
4101
+ if (!r.ok) throw new Error(`${r.status}`);
4102
+ return r.json();
4103
+ });
3874
4104
  if (data.cwd) {
3875
4105
  serverCwd = data.cwd;
3876
4106
  document.getElementById('ns-cwd').placeholder = data.cwd;
@@ -3928,10 +4158,16 @@
3928
4158
  headers: { 'Content-Type': 'application/json' },
3929
4159
  body: JSON.stringify(body),
3930
4160
  });
4161
+ if (!res.ok) {
4162
+ console.error(`Failed to create session: ${res.status}`);
4163
+ return;
4164
+ }
3931
4165
  const data = await res.json();
3932
4166
 
3933
4167
  // Fetch full session list to get the new session data
3934
- const list = await fetch('/api/sessions').then((r) => r.json());
4168
+ const listRes = await fetch('/api/sessions');
4169
+ if (!listRes.ok) throw new Error(`Failed to list sessions: ${listRes.status}`);
4170
+ const list = await listRes.json();
3935
4171
  const newSession = list.find((s) => s.id === data.id);
3936
4172
  if (newSession) {
3937
4173
  addSession(newSession);
@@ -3951,7 +4187,9 @@
3951
4187
  function startPolling() {
3952
4188
  setInterval(async () => {
3953
4189
  try {
3954
- const list = await fetch('/api/sessions').then((r) => r.json());
4190
+ const pollRes = await fetch('/api/sessions');
4191
+ if (!pollRes.ok) throw new Error(`${pollRes.status}`);
4192
+ const list = await pollRes.json();
3955
4193
  const serverIds = new Set(list.map((s) => s.id));
3956
4194
 
3957
4195
  // Add new sessions created elsewhere
@@ -4164,6 +4402,19 @@
4164
4402
  category: 'Session',
4165
4403
  action: () => openNewSessionModal(),
4166
4404
  },
4405
+ {
4406
+ icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>',
4407
+ label: 'Upload files',
4408
+ category: 'Session',
4409
+ action: () => {
4410
+ if (!activeId) {
4411
+ showToast('No active session');
4412
+ return;
4413
+ }
4414
+ document.getElementById('upload-input').value = '';
4415
+ document.getElementById('upload-input').click();
4416
+ },
4417
+ },
4167
4418
  {
4168
4419
  icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
4169
4420
  label: 'Close tab',
@@ -4348,8 +4599,8 @@
4348
4599
  btn.innerHTML =
4349
4600
  '<span class="palette-action-icon">' + a.icon + '</span>' + esc(a.label);
4350
4601
  btn.addEventListener('click', () => {
4351
- if (!a.keepOpen) closePalette();
4352
4602
  a.action();
4603
+ if (!a.keepOpen) closePalette();
4353
4604
  });
4354
4605
  body.appendChild(btn);
4355
4606
  });
package/src/auth.js CHANGED
@@ -177,7 +177,7 @@ function createAuth(password) {
177
177
  const shareTokens = new Map(); // share tokens: token -> expiry
178
178
 
179
179
  // Periodically clean up expired tokens and stale rate-limit entries
180
- setInterval(
180
+ const cleanupInterval = setInterval(
181
181
  () => {
182
182
  const now = Date.now();
183
183
  for (const [token, expiry] of tokens) {
@@ -296,6 +296,7 @@ function createAuth(password) {
296
296
  rateLimit,
297
297
  parseCookies,
298
298
  loginHTML: LOGIN_HTML,
299
+ cleanup: () => clearInterval(cleanupInterval),
299
300
  };
300
301
  }
301
302
 
package/src/cli.js CHANGED
@@ -270,6 +270,10 @@ function parseArgs() {
270
270
  publicTunnel = true;
271
271
  } else if (args[i].startsWith('--password=')) {
272
272
  password = args[i].split('=')[1];
273
+ if (!password) {
274
+ console.error('Error: --password= requires a non-empty value\n');
275
+ process.exit(1);
276
+ }
273
277
  explicitPassword = true;
274
278
  } else if (args[i] === '--help' || args[i] === '-h') {
275
279
  printHelp();
@@ -286,6 +290,10 @@ function parseArgs() {
286
290
  explicitPassword = true;
287
291
  } else if (args[i] === '--port' && args[i + 1]) {
288
292
  port = parseInt(args[++i], 10);
293
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
294
+ console.error('Error: --port must be a number between 1 and 65535\n');
295
+ process.exit(1);
296
+ }
289
297
  } else if (args[i] === '--lan') {
290
298
  host = '0.0.0.0';
291
299
  } else if (args[i] === '--host' && args[i + 1]) {
@@ -307,6 +315,12 @@ function parseArgs() {
307
315
  }
308
316
  }
309
317
 
318
+ const validLogLevels = ['error', 'warn', 'info', 'debug'];
319
+ if (!validLogLevels.includes(logLevel)) {
320
+ console.error(`Error: --log-level must be one of: ${validLogLevels.join(', ')}\n`);
321
+ process.exit(1);
322
+ }
323
+
310
324
  // Default: auto-generate password if none specified
311
325
  if (!explicitPassword && !password) {
312
326
  password = crypto.randomBytes(16).toString('base64url');
package/src/client.js CHANGED
@@ -80,7 +80,7 @@ function createTerminalClient({
80
80
  try {
81
81
  msg = JSON.parse(raw);
82
82
  } catch {
83
- return;
83
+ return; // Silently drop unparseable messages from server
84
84
  }
85
85
 
86
86
  if (msg.type === 'auth_ok') {
package/src/preview.js CHANGED
@@ -96,7 +96,7 @@ function createPreviewProxy() {
96
96
  proxyReq.on('error', (err) => {
97
97
  log.warn(`Preview proxy error (port ${port}): ${err.message}`);
98
98
  if (!res.headersSent) {
99
- res.status(502).json({ error: `Bad gateway: ${err.message}` });
99
+ res.status(502).json({ error: 'Bad gateway: upstream server is not responding' });
100
100
  }
101
101
  });
102
102
 
package/src/routes.js CHANGED
@@ -185,9 +185,10 @@ function setupRoutes(app, { auth, sessions, config, state }) {
185
185
  rows: typeof rows === 'number' && rows > 0 && rows <= 200 ? Math.floor(rows) : undefined,
186
186
  });
187
187
  } catch (err) {
188
- return res.status(400).json({ error: err.message || 'Failed to create session' });
188
+ log.warn(`Session creation failed: ${err.message}`);
189
+ return res.status(400).json({ error: 'Failed to create session' });
189
190
  }
190
- res.json({ id, url: `/terminal?id=${id}` });
191
+ res.status(201).json({ id, url: `/terminal?id=${id}` });
191
192
  });
192
193
 
193
194
  // Available shells
@@ -220,7 +221,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
220
221
 
221
222
  app.delete('/api/sessions/:id', auth.middleware, (req, res) => {
222
223
  if (sessions.delete(req.params.id)) {
223
- res.json({ ok: true });
224
+ res.status(204).end();
224
225
  } else {
225
226
  res.status(404).json({ error: 'not found' });
226
227
  }
@@ -288,7 +289,7 @@ function setupRoutes(app, { auth, sessions, config, state }) {
288
289
  fs.writeFileSync(filepath, buffer);
289
290
  uploadedFiles.set(id, filepath);
290
291
  log.info(`Upload: ${filename} (${buffer.length} bytes)`);
291
- res.json({ id, url: `/uploads/${id}`, path: filepath });
292
+ res.status(201).json({ id, url: `/uploads/${id}`, path: filepath });
292
293
  });
293
294
 
294
295
  req.on('error', (err) => {
@@ -308,6 +309,112 @@ function setupRoutes(app, { auth, sessions, config, state }) {
308
309
  res.sendFile(filepath);
309
310
  });
310
311
 
312
+ // General file upload to a session's working directory
313
+ app.post('/api/sessions/:id/upload', apiRateLimit, auth.middleware, (req, res) => {
314
+ const session = sessions.get(req.params.id);
315
+ if (!session) {
316
+ return res.status(404).json({ error: 'Session not found' });
317
+ }
318
+
319
+ const rawName = req.headers['x-filename'];
320
+ if (!rawName || typeof rawName !== 'string') {
321
+ return res.status(400).json({ error: 'Missing X-Filename header' });
322
+ }
323
+
324
+ // Sanitize: take only the basename, strip control chars, collapse whitespace
325
+ const sanitized = path
326
+ .basename(rawName)
327
+ .replace(/[\x00-\x1f]/g, '')
328
+ .replace(/\s+/g, ' ')
329
+ .trim();
330
+ if (!sanitized || sanitized === '.' || sanitized === '..') {
331
+ return res.status(400).json({ error: 'Invalid filename' });
332
+ }
333
+
334
+ // Resolve target directory: optional X-Target-Dir header, falls back to session cwd
335
+ const rawTargetDir = req.headers['x-target-dir'];
336
+ let targetDir = session.cwd;
337
+ if (rawTargetDir && typeof rawTargetDir === 'string') {
338
+ if (!path.isAbsolute(rawTargetDir)) {
339
+ return res.status(400).json({ error: 'Target directory must be an absolute path' });
340
+ }
341
+ const resolved = path.resolve(rawTargetDir);
342
+ try {
343
+ if (fs.statSync(resolved).isDirectory()) {
344
+ targetDir = resolved;
345
+ } else {
346
+ return res.status(400).json({ error: 'Target directory is not a directory' });
347
+ }
348
+ } catch {
349
+ return res.status(400).json({ error: 'Target directory does not exist' });
350
+ }
351
+ }
352
+ // Defense-in-depth: ensure destPath is still inside targetDir after join
353
+ const destPath = path.join(targetDir, sanitized);
354
+ if (
355
+ !path.resolve(destPath).startsWith(path.resolve(targetDir) + path.sep) &&
356
+ path.resolve(destPath) !== path.resolve(targetDir)
357
+ ) {
358
+ return res.status(400).json({ error: 'Invalid filename' });
359
+ }
360
+
361
+ const chunks = [];
362
+ let size = 0;
363
+ let aborted = false;
364
+ const limit = 10 * 1024 * 1024;
365
+
366
+ req.on('data', (chunk) => {
367
+ if (aborted) return;
368
+ size += chunk.length;
369
+ if (size > limit) {
370
+ aborted = true;
371
+ log.warn(`File upload rejected: too large (${size} bytes)`);
372
+ res.status(413).json({ error: 'File too large (max 10 MB)' });
373
+ req.resume();
374
+ return;
375
+ }
376
+ chunks.push(chunk);
377
+ });
378
+
379
+ req.on('end', () => {
380
+ if (aborted) return;
381
+ const buffer = Buffer.concat(chunks);
382
+ if (!buffer.length) {
383
+ return res.status(400).json({ error: 'Empty file' });
384
+ }
385
+
386
+ // Atomic write with dedup: use wx flag to fail on existing file, retry with suffix
387
+ const ext = path.extname(sanitized);
388
+ const base = path.basename(sanitized, ext);
389
+ let destPath = path.join(targetDir, sanitized);
390
+ let written = false;
391
+ for (let n = 0; n < 100; n++) {
392
+ const candidate = n === 0 ? destPath : path.join(targetDir, `${base} (${n})${ext}`);
393
+ try {
394
+ fs.writeFileSync(candidate, buffer, { flag: 'wx' });
395
+ destPath = candidate;
396
+ written = true;
397
+ break;
398
+ } catch (err) {
399
+ if (err.code === 'EEXIST') continue;
400
+ log.error(`File upload write error: ${err.message}`);
401
+ return res.status(500).json({ error: 'Failed to write file' });
402
+ }
403
+ }
404
+ if (!written) {
405
+ return res.status(409).json({ error: 'Too many filename collisions' });
406
+ }
407
+ const finalName = path.basename(destPath);
408
+ log.info(`File upload: ${finalName} → ${targetDir} (${buffer.length} bytes)`);
409
+ res.status(201).json({ name: finalName, path: destPath, size: buffer.length });
410
+ });
411
+
412
+ req.on('error', (err) => {
413
+ log.error(`File upload error: ${err.message}`);
414
+ res.status(500).json({ error: 'Upload failed' });
415
+ });
416
+ });
417
+
311
418
  // Directory listing for folder browser
312
419
  app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
313
420
  const query = req.query.q || config.cwd + path.sep;
package/src/server.js CHANGED
@@ -83,10 +83,14 @@ function createTermBeamServer(overrides = {}) {
83
83
  function shutdown() {
84
84
  if (shuttingDown) return;
85
85
  shuttingDown = true;
86
+ auth.cleanup();
86
87
  sessions.shutdown();
87
88
  cleanupUploadedFiles();
88
89
  cleanupTunnel();
89
90
  removeConnectionConfig();
91
+ for (const client of wss.clients) {
92
+ client.close(1001, 'Server shutting down');
93
+ }
90
94
  server.close();
91
95
  wss.close();
92
96
  }
package/src/sessions.js CHANGED
@@ -1,30 +1,10 @@
1
1
  const crypto = require('crypto');
2
2
  const path = require('path');
3
- const { execSync, exec } = require('child_process');
4
- const fs = require('fs');
3
+ const { exec } = require('child_process');
5
4
  const pty = require('node-pty');
6
5
  const log = require('./logger');
7
6
  const { getGitInfo } = require('./git');
8
7
 
9
- function _getProcessCwd(pid) {
10
- try {
11
- if (process.platform === 'linux') {
12
- return fs.readlinkSync(`/proc/${pid}/cwd`);
13
- }
14
- if (process.platform === 'darwin') {
15
- const out = execSync(`lsof -a -p ${pid} -d cwd -Fn`, {
16
- stdio: 'pipe',
17
- timeout: 2000,
18
- }).toString();
19
- const match = out.match(/\nn(.+)/);
20
- if (match) return match[1];
21
- }
22
- } catch {
23
- /* process may have exited */
24
- }
25
- return null;
26
- }
27
-
28
8
  // Cache git info per session to avoid blocking the event loop on every list() call.
29
9
  // lsof + git commands take ~200-500ms and block WebSocket traffic, causing
30
10
  // xterm.js cursor position report responses to leak as visible text.
@@ -69,6 +49,7 @@ function scheduleGitRefresh(sessionId, pid, originalCwd) {
69
49
  }
70
50
 
71
51
  exec(cmd, { timeout: 2000 }, (err, stdout) => {
52
+ if (err) log.debug(`Git cwd detection failed: ${err.message}`);
72
53
  let liveCwd = originalCwd;
73
54
  if (!err && stdout) {
74
55
  if (process.platform === 'darwin') {
package/src/tunnel.js CHANGED
@@ -67,21 +67,6 @@ function savePersistedTunnel(id) {
67
67
  );
68
68
  }
69
69
 
70
- function _deletePersisted() {
71
- const persisted = loadPersistedTunnel();
72
- if (persisted) {
73
- try {
74
- if (SAFE_ID_RE.test(persisted.tunnelId)) {
75
- execFileSync(devtunnelCmd, ['delete', persisted.tunnelId, '-f'], { stdio: 'pipe' });
76
- log.info(`Deleted persisted tunnel ${persisted.tunnelId}`);
77
- }
78
- } catch {}
79
- try {
80
- fs.unlinkSync(TUNNEL_CONFIG_PATH);
81
- } catch {}
82
- }
83
- }
84
-
85
70
  function isTunnelValid(id) {
86
71
  try {
87
72
  if (!SAFE_ID_RE.test(id)) return false;
package/src/websocket.js CHANGED
@@ -17,6 +17,10 @@ function recalcPtySize(session) {
17
17
  }
18
18
 
19
19
  function setupWebSocket(wss, { auth, sessions }) {
20
+ const wsAuthAttempts = new Map(); // ip -> [timestamps]
21
+ const WS_AUTH_WINDOW = 60 * 1000; // 1 minute
22
+ const WS_MAX_AUTH_ATTEMPTS = 5;
23
+
20
24
  wss.on('connection', (ws, req) => {
21
25
  const origin = req.headers.origin;
22
26
  if (origin) {
@@ -51,11 +55,26 @@ function setupWebSocket(wss, { auth, sessions }) {
51
55
  const msg = JSON.parse(raw);
52
56
 
53
57
  if (msg.type === 'auth') {
58
+ const ip = req.socket.remoteAddress;
59
+ const now = Date.now();
60
+ const attempts = (wsAuthAttempts.get(ip) || []).filter((t) => now - t < WS_AUTH_WINDOW);
61
+
62
+ if (attempts.length >= WS_MAX_AUTH_ATTEMPTS) {
63
+ log.warn(`WS: rate limit exceeded for ${ip}`);
64
+ ws.send(
65
+ JSON.stringify({ type: 'error', message: 'Too many attempts. Try again later.' }),
66
+ );
67
+ ws.close();
68
+ return;
69
+ }
70
+
54
71
  if (msg.password === auth.password || auth.validateToken(msg.token)) {
55
72
  authenticated = true;
56
73
  ws.send(JSON.stringify({ type: 'auth_ok' }));
57
74
  log.info('WS: auth success');
58
75
  } else {
76
+ attempts.push(now);
77
+ wsAuthAttempts.set(ip, attempts);
59
78
  log.warn('WS: auth failed');
60
79
  ws.send(JSON.stringify({ type: 'error', message: 'Unauthorized' }));
61
80
  ws.close();