termbeam 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/public/terminal.html +226 -1
- package/src/routes.js +106 -0
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
|
package/package.json
CHANGED
package/public/terminal.html
CHANGED
|
@@ -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="[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>
|
|
@@ -2449,6 +2499,7 @@
|
|
|
2449
2499
|
setupKeyBar();
|
|
2450
2500
|
setupPaste();
|
|
2451
2501
|
setupImagePaste();
|
|
2502
|
+
setupUpload();
|
|
2452
2503
|
setupSelectMode();
|
|
2453
2504
|
setupNewSessionModal();
|
|
2454
2505
|
setupPreviewModal();
|
|
@@ -3739,6 +3790,167 @@
|
|
|
3739
3790
|
);
|
|
3740
3791
|
}
|
|
3741
3792
|
|
|
3793
|
+
// ===== File Upload =====
|
|
3794
|
+
function setupUpload() {
|
|
3795
|
+
const uploadInput = document.getElementById('upload-input');
|
|
3796
|
+
const uploadModal = document.getElementById('upload-modal');
|
|
3797
|
+
const uploadDirInput = document.getElementById('upload-dir');
|
|
3798
|
+
const uploadFileList = document.getElementById('upload-file-list');
|
|
3799
|
+
const uploadConfirmBtn = document.getElementById('upload-confirm');
|
|
3800
|
+
const uploadCancelBtn = document.getElementById('upload-cancel');
|
|
3801
|
+
const uploadBrowseBtn = document.getElementById('upload-browse-btn');
|
|
3802
|
+
|
|
3803
|
+
let pendingFiles = null;
|
|
3804
|
+
|
|
3805
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
|
3806
|
+
|
|
3807
|
+
function openUploadModal(files) {
|
|
3808
|
+
pendingFiles = files;
|
|
3809
|
+
const ms = managed.get(activeId);
|
|
3810
|
+
const cwd = (ms && ms.cwd) || '';
|
|
3811
|
+
uploadDirInput.value = cwd;
|
|
3812
|
+
let hasOversized = false;
|
|
3813
|
+
uploadFileList.innerHTML = '';
|
|
3814
|
+
Array.from(files).forEach((f) => {
|
|
3815
|
+
const oversized = f.size > MAX_FILE_SIZE;
|
|
3816
|
+
if (oversized) hasOversized = true;
|
|
3817
|
+
const row = document.createElement('div');
|
|
3818
|
+
if (oversized) row.style.color = '#f87171';
|
|
3819
|
+
row.textContent =
|
|
3820
|
+
'📄 ' +
|
|
3821
|
+
f.name +
|
|
3822
|
+
' (' +
|
|
3823
|
+
formatSize(f.size) +
|
|
3824
|
+
')' +
|
|
3825
|
+
(oversized ? ' exceeds 10 MB limit' : '');
|
|
3826
|
+
uploadFileList.appendChild(row);
|
|
3827
|
+
});
|
|
3828
|
+
uploadConfirmBtn.disabled = hasOversized;
|
|
3829
|
+
uploadConfirmBtn.style.opacity = hasOversized ? '0.5' : '1';
|
|
3830
|
+
uploadModal.classList.add('visible');
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
function closeUploadModal() {
|
|
3834
|
+
uploadModal.classList.remove('visible');
|
|
3835
|
+
closeBrowseDropdown();
|
|
3836
|
+
pendingFiles = null;
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
function formatSize(bytes) {
|
|
3840
|
+
if (bytes < 1024) return bytes + ' B';
|
|
3841
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
3842
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
uploadInput.addEventListener('change', () => {
|
|
3846
|
+
const files = uploadInput.files;
|
|
3847
|
+
if (!files || !files.length || !activeId) return;
|
|
3848
|
+
openUploadModal(files);
|
|
3849
|
+
});
|
|
3850
|
+
|
|
3851
|
+
uploadCancelBtn.addEventListener('click', closeUploadModal);
|
|
3852
|
+
uploadModal.addEventListener('click', (e) => {
|
|
3853
|
+
if (e.target === uploadModal) closeUploadModal();
|
|
3854
|
+
});
|
|
3855
|
+
|
|
3856
|
+
// Directory browsing (reuse /api/dirs)
|
|
3857
|
+
let browseDropdown = null;
|
|
3858
|
+
|
|
3859
|
+
function closeBrowseDropdown() {
|
|
3860
|
+
if (browseDropdown) {
|
|
3861
|
+
browseDropdown.remove();
|
|
3862
|
+
browseDropdown = null;
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
|
|
3866
|
+
// Close dropdown when clicking outside
|
|
3867
|
+
document.addEventListener('click', (e) => {
|
|
3868
|
+
if (
|
|
3869
|
+
browseDropdown &&
|
|
3870
|
+
!browseDropdown.contains(e.target) &&
|
|
3871
|
+
e.target !== uploadBrowseBtn
|
|
3872
|
+
) {
|
|
3873
|
+
closeBrowseDropdown();
|
|
3874
|
+
}
|
|
3875
|
+
});
|
|
3876
|
+
|
|
3877
|
+
uploadBrowseBtn.addEventListener('click', async () => {
|
|
3878
|
+
if (browseDropdown) {
|
|
3879
|
+
closeBrowseDropdown();
|
|
3880
|
+
return;
|
|
3881
|
+
}
|
|
3882
|
+
const q = uploadDirInput.value || '';
|
|
3883
|
+
try {
|
|
3884
|
+
const res = await fetch('/api/dirs?q=' + encodeURIComponent(q ? q + '/' : ''), {
|
|
3885
|
+
credentials: 'same-origin',
|
|
3886
|
+
});
|
|
3887
|
+
const data = await res.json();
|
|
3888
|
+
if (!data.dirs || !data.dirs.length) return;
|
|
3889
|
+
browseDropdown = document.createElement('div');
|
|
3890
|
+
browseDropdown.style.cssText =
|
|
3891
|
+
'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);';
|
|
3892
|
+
data.dirs.forEach((d) => {
|
|
3893
|
+
const opt = document.createElement('div');
|
|
3894
|
+
opt.textContent = d;
|
|
3895
|
+
opt.style.cssText =
|
|
3896
|
+
'padding:6px 10px;cursor:pointer;font-size:13px;color:var(--text);';
|
|
3897
|
+
opt.addEventListener('mouseenter', () => (opt.style.background = 'var(--hover)'));
|
|
3898
|
+
opt.addEventListener('mouseleave', () => (opt.style.background = 'none'));
|
|
3899
|
+
opt.addEventListener('click', () => {
|
|
3900
|
+
uploadDirInput.value = d;
|
|
3901
|
+
closeBrowseDropdown();
|
|
3902
|
+
});
|
|
3903
|
+
browseDropdown.appendChild(opt);
|
|
3904
|
+
});
|
|
3905
|
+
uploadBrowseBtn.parentElement.style.position = 'relative';
|
|
3906
|
+
uploadBrowseBtn.parentElement.appendChild(browseDropdown);
|
|
3907
|
+
} catch {}
|
|
3908
|
+
});
|
|
3909
|
+
|
|
3910
|
+
uploadConfirmBtn.addEventListener('click', async () => {
|
|
3911
|
+
if (!pendingFiles || !pendingFiles.length || !activeId) return;
|
|
3912
|
+
const targetDir = uploadDirInput.value.trim();
|
|
3913
|
+
const filesToUpload = Array.from(pendingFiles);
|
|
3914
|
+
closeUploadModal();
|
|
3915
|
+
|
|
3916
|
+
let uploaded = 0;
|
|
3917
|
+
let failed = 0;
|
|
3918
|
+
|
|
3919
|
+
for (const file of filesToUpload) {
|
|
3920
|
+
try {
|
|
3921
|
+
const headers = {
|
|
3922
|
+
'Content-Type': file.type || 'application/octet-stream',
|
|
3923
|
+
'X-Filename': file.name,
|
|
3924
|
+
};
|
|
3925
|
+
if (targetDir) headers['X-Target-Dir'] = targetDir;
|
|
3926
|
+
const res = await fetch(`/api/sessions/${activeId}/upload`, {
|
|
3927
|
+
method: 'POST',
|
|
3928
|
+
headers,
|
|
3929
|
+
body: file,
|
|
3930
|
+
credentials: 'same-origin',
|
|
3931
|
+
});
|
|
3932
|
+
if (!res.ok) {
|
|
3933
|
+
const err = await res.json().catch(() => ({}));
|
|
3934
|
+
throw new Error(err.error || 'Upload failed');
|
|
3935
|
+
}
|
|
3936
|
+
await res.json();
|
|
3937
|
+
uploaded++;
|
|
3938
|
+
} catch (err) {
|
|
3939
|
+
failed++;
|
|
3940
|
+
console.error('Upload error:', file.name, err);
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
if (uploaded > 0) {
|
|
3945
|
+
const dir = targetDir || 'session directory';
|
|
3946
|
+
showToast(`${uploaded} file${uploaded > 1 ? 's' : ''} uploaded to ${dir}`);
|
|
3947
|
+
}
|
|
3948
|
+
if (failed > 0) {
|
|
3949
|
+
showToast(`${failed} file${failed > 1 ? 's' : ''} failed to upload`);
|
|
3950
|
+
}
|
|
3951
|
+
});
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3742
3954
|
// ===== New Session Modal =====
|
|
3743
3955
|
let shellsLoaded = false;
|
|
3744
3956
|
|
|
@@ -4164,6 +4376,19 @@
|
|
|
4164
4376
|
category: 'Session',
|
|
4165
4377
|
action: () => openNewSessionModal(),
|
|
4166
4378
|
},
|
|
4379
|
+
{
|
|
4380
|
+
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>',
|
|
4381
|
+
label: 'Upload files',
|
|
4382
|
+
category: 'Session',
|
|
4383
|
+
action: () => {
|
|
4384
|
+
if (!activeId) {
|
|
4385
|
+
showToast('No active session');
|
|
4386
|
+
return;
|
|
4387
|
+
}
|
|
4388
|
+
document.getElementById('upload-input').value = '';
|
|
4389
|
+
document.getElementById('upload-input').click();
|
|
4390
|
+
},
|
|
4391
|
+
},
|
|
4167
4392
|
{
|
|
4168
4393
|
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
4394
|
label: 'Close tab',
|
|
@@ -4348,8 +4573,8 @@
|
|
|
4348
4573
|
btn.innerHTML =
|
|
4349
4574
|
'<span class="palette-action-icon">' + a.icon + '</span>' + esc(a.label);
|
|
4350
4575
|
btn.addEventListener('click', () => {
|
|
4351
|
-
if (!a.keepOpen) closePalette();
|
|
4352
4576
|
a.action();
|
|
4577
|
+
if (!a.keepOpen) closePalette();
|
|
4353
4578
|
});
|
|
4354
4579
|
body.appendChild(btn);
|
|
4355
4580
|
});
|
package/src/routes.js
CHANGED
|
@@ -308,6 +308,112 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
308
308
|
res.sendFile(filepath);
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
+
// General file upload to a session's working directory
|
|
312
|
+
app.post('/api/sessions/:id/upload', apiRateLimit, auth.middleware, (req, res) => {
|
|
313
|
+
const session = sessions.get(req.params.id);
|
|
314
|
+
if (!session) {
|
|
315
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const rawName = req.headers['x-filename'];
|
|
319
|
+
if (!rawName || typeof rawName !== 'string') {
|
|
320
|
+
return res.status(400).json({ error: 'Missing X-Filename header' });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Sanitize: take only the basename, strip control chars, collapse whitespace
|
|
324
|
+
const sanitized = path
|
|
325
|
+
.basename(rawName)
|
|
326
|
+
.replace(/[\x00-\x1f]/g, '')
|
|
327
|
+
.replace(/\s+/g, ' ')
|
|
328
|
+
.trim();
|
|
329
|
+
if (!sanitized || sanitized === '.' || sanitized === '..') {
|
|
330
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Resolve target directory: optional X-Target-Dir header, falls back to session cwd
|
|
334
|
+
const rawTargetDir = req.headers['x-target-dir'];
|
|
335
|
+
let targetDir = session.cwd;
|
|
336
|
+
if (rawTargetDir && typeof rawTargetDir === 'string') {
|
|
337
|
+
if (!path.isAbsolute(rawTargetDir)) {
|
|
338
|
+
return res.status(400).json({ error: 'Target directory must be an absolute path' });
|
|
339
|
+
}
|
|
340
|
+
const resolved = path.resolve(rawTargetDir);
|
|
341
|
+
try {
|
|
342
|
+
if (fs.statSync(resolved).isDirectory()) {
|
|
343
|
+
targetDir = resolved;
|
|
344
|
+
} else {
|
|
345
|
+
return res.status(400).json({ error: 'Target directory is not a directory' });
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
return res.status(400).json({ error: 'Target directory does not exist' });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Defense-in-depth: ensure destPath is still inside targetDir after join
|
|
352
|
+
const destPath = path.join(targetDir, sanitized);
|
|
353
|
+
if (
|
|
354
|
+
!path.resolve(destPath).startsWith(path.resolve(targetDir) + path.sep) &&
|
|
355
|
+
path.resolve(destPath) !== path.resolve(targetDir)
|
|
356
|
+
) {
|
|
357
|
+
return res.status(400).json({ error: 'Invalid filename' });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const chunks = [];
|
|
361
|
+
let size = 0;
|
|
362
|
+
let aborted = false;
|
|
363
|
+
const limit = 10 * 1024 * 1024;
|
|
364
|
+
|
|
365
|
+
req.on('data', (chunk) => {
|
|
366
|
+
if (aborted) return;
|
|
367
|
+
size += chunk.length;
|
|
368
|
+
if (size > limit) {
|
|
369
|
+
aborted = true;
|
|
370
|
+
log.warn(`File upload rejected: too large (${size} bytes)`);
|
|
371
|
+
res.status(413).json({ error: 'File too large (max 10 MB)' });
|
|
372
|
+
req.resume();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
chunks.push(chunk);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
req.on('end', () => {
|
|
379
|
+
if (aborted) return;
|
|
380
|
+
const buffer = Buffer.concat(chunks);
|
|
381
|
+
if (!buffer.length) {
|
|
382
|
+
return res.status(400).json({ error: 'Empty file' });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Atomic write with dedup: use wx flag to fail on existing file, retry with suffix
|
|
386
|
+
const ext = path.extname(sanitized);
|
|
387
|
+
const base = path.basename(sanitized, ext);
|
|
388
|
+
let destPath = path.join(targetDir, sanitized);
|
|
389
|
+
let written = false;
|
|
390
|
+
for (let n = 0; n < 100; n++) {
|
|
391
|
+
const candidate = n === 0 ? destPath : path.join(targetDir, `${base} (${n})${ext}`);
|
|
392
|
+
try {
|
|
393
|
+
fs.writeFileSync(candidate, buffer, { flag: 'wx' });
|
|
394
|
+
destPath = candidate;
|
|
395
|
+
written = true;
|
|
396
|
+
break;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (err.code === 'EEXIST') continue;
|
|
399
|
+
log.error(`File upload write error: ${err.message}`);
|
|
400
|
+
return res.status(500).json({ error: 'Failed to write file' });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (!written) {
|
|
404
|
+
return res.status(409).json({ error: 'Too many filename collisions' });
|
|
405
|
+
}
|
|
406
|
+
const finalName = path.basename(destPath);
|
|
407
|
+
log.info(`File upload: ${finalName} → ${targetDir} (${buffer.length} bytes)`);
|
|
408
|
+
res.json({ name: finalName, path: destPath, size: buffer.length });
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
req.on('error', (err) => {
|
|
412
|
+
log.error(`File upload error: ${err.message}`);
|
|
413
|
+
res.status(500).json({ error: 'Upload failed' });
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
311
417
|
// Directory listing for folder browser
|
|
312
418
|
app.get('/api/dirs', apiRateLimit, auth.middleware, (req, res) => {
|
|
313
419
|
const query = req.query.q || config.cwd + path.sep;
|