@mjasano/devtunnel 1.0.0 → 1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mjasano/devtunnel",
3
- "version": "1.0.0",
4
- "description": "Web terminal with tunnel manager - access your dev environment from anywhere",
3
+ "version": "1.1.0",
4
+ "description": "Web terminal with code editor and tunnel manager - access your dev environment from anywhere",
5
5
  "main": "server.js",
6
6
  "bin": {
7
7
  "devtunnel": "./bin/cli.js"
package/public/index.html CHANGED
@@ -407,6 +407,203 @@
407
407
  border-color: #f85149;
408
408
  }
409
409
 
410
+ /* Tab Bar */
411
+ .tab-bar {
412
+ display: flex;
413
+ background-color: #161b22;
414
+ border-bottom: 1px solid #30363d;
415
+ padding: 0 12px;
416
+ }
417
+
418
+ .tab {
419
+ padding: 10px 20px;
420
+ font-size: 13px;
421
+ color: #8b949e;
422
+ cursor: pointer;
423
+ border-bottom: 2px solid transparent;
424
+ transition: all 0.15s ease;
425
+ display: flex;
426
+ align-items: center;
427
+ gap: 8px;
428
+ }
429
+
430
+ .tab:hover {
431
+ color: #c9d1d9;
432
+ background-color: #21262d;
433
+ }
434
+
435
+ .tab.active {
436
+ color: #c9d1d9;
437
+ border-bottom-color: #58a6ff;
438
+ }
439
+
440
+ .tab-icon {
441
+ font-size: 14px;
442
+ }
443
+
444
+ /* Editor Container */
445
+ #editor-container {
446
+ flex: 1;
447
+ display: none;
448
+ flex-direction: column;
449
+ background-color: #0d1117;
450
+ overflow: hidden;
451
+ }
452
+
453
+ #editor-container.active {
454
+ display: flex;
455
+ }
456
+
457
+ #terminal-container.hidden {
458
+ display: none;
459
+ }
460
+
461
+ .editor-tabs {
462
+ display: flex;
463
+ background-color: #161b22;
464
+ border-bottom: 1px solid #30363d;
465
+ overflow-x: auto;
466
+ min-height: 35px;
467
+ }
468
+
469
+ .editor-tab {
470
+ display: flex;
471
+ align-items: center;
472
+ gap: 8px;
473
+ padding: 8px 12px;
474
+ font-size: 12px;
475
+ color: #8b949e;
476
+ background-color: #0d1117;
477
+ border-right: 1px solid #30363d;
478
+ cursor: pointer;
479
+ white-space: nowrap;
480
+ }
481
+
482
+ .editor-tab:hover {
483
+ background-color: #161b22;
484
+ }
485
+
486
+ .editor-tab.active {
487
+ color: #c9d1d9;
488
+ background-color: #161b22;
489
+ }
490
+
491
+ .editor-tab.modified::after {
492
+ content: '';
493
+ width: 6px;
494
+ height: 6px;
495
+ background-color: #d29922;
496
+ border-radius: 50%;
497
+ }
498
+
499
+ .editor-tab-close {
500
+ width: 16px;
501
+ height: 16px;
502
+ display: flex;
503
+ align-items: center;
504
+ justify-content: center;
505
+ border-radius: 4px;
506
+ font-size: 14px;
507
+ line-height: 1;
508
+ }
509
+
510
+ .editor-tab-close:hover {
511
+ background-color: #30363d;
512
+ }
513
+
514
+ #monaco-editor {
515
+ flex: 1;
516
+ min-height: 0;
517
+ }
518
+
519
+ .editor-empty {
520
+ flex: 1;
521
+ display: flex;
522
+ flex-direction: column;
523
+ align-items: center;
524
+ justify-content: center;
525
+ color: #6e7681;
526
+ font-size: 14px;
527
+ }
528
+
529
+ .editor-empty-icon {
530
+ font-size: 48px;
531
+ margin-bottom: 16px;
532
+ opacity: 0.5;
533
+ }
534
+
535
+ /* File Tree */
536
+ .file-tree {
537
+ font-size: 12px;
538
+ }
539
+
540
+ .file-item {
541
+ display: flex;
542
+ align-items: center;
543
+ gap: 6px;
544
+ padding: 4px 8px;
545
+ cursor: pointer;
546
+ border-radius: 4px;
547
+ }
548
+
549
+ .file-item:hover {
550
+ background-color: #21262d;
551
+ }
552
+
553
+ .file-item.active {
554
+ background-color: rgba(56, 139, 253, 0.15);
555
+ }
556
+
557
+ .file-item-icon {
558
+ width: 16px;
559
+ text-align: center;
560
+ flex-shrink: 0;
561
+ }
562
+
563
+ .file-item-name {
564
+ overflow: hidden;
565
+ text-overflow: ellipsis;
566
+ white-space: nowrap;
567
+ }
568
+
569
+ .file-item.directory > .file-item-icon {
570
+ color: #58a6ff;
571
+ }
572
+
573
+ .file-item.file > .file-item-icon {
574
+ color: #8b949e;
575
+ }
576
+
577
+ .file-children {
578
+ margin-left: 12px;
579
+ }
580
+
581
+ .breadcrumb {
582
+ display: flex;
583
+ align-items: center;
584
+ gap: 4px;
585
+ padding: 8px 12px;
586
+ font-size: 11px;
587
+ color: #8b949e;
588
+ border-bottom: 1px solid #30363d;
589
+ overflow-x: auto;
590
+ }
591
+
592
+ .breadcrumb-item {
593
+ cursor: pointer;
594
+ padding: 2px 4px;
595
+ border-radius: 4px;
596
+ }
597
+
598
+ .breadcrumb-item:hover {
599
+ background-color: #21262d;
600
+ color: #c9d1d9;
601
+ }
602
+
603
+ .breadcrumb-sep {
604
+ color: #484f58;
605
+ }
606
+
410
607
  @keyframes slideIn {
411
608
  from {
412
609
  transform: translateY(20px);
@@ -454,12 +651,51 @@
454
651
  </div>
455
652
  </div>
456
653
 
654
+ <!-- Tab Bar -->
655
+ <div class="tab-bar">
656
+ <div class="tab active" data-tab="terminal" onclick="switchTab('terminal')">
657
+ <span class="tab-icon">></span>
658
+ Terminal
659
+ </div>
660
+ <div class="tab" data-tab="editor" onclick="switchTab('editor')">
661
+ <span class="tab-icon">{}</span>
662
+ Editor
663
+ </div>
664
+ </div>
665
+
457
666
  <div class="main-container">
458
667
  <div id="terminal-container">
459
668
  <div id="terminal"></div>
460
669
  </div>
461
670
 
671
+ <div id="editor-container">
672
+ <div class="editor-tabs" id="editor-tabs"></div>
673
+ <div id="monaco-editor"></div>
674
+ <div class="editor-empty" id="editor-empty">
675
+ <div class="editor-empty-icon">{}</div>
676
+ <div>Select a file to edit</div>
677
+ <div style="margin-top: 8px; font-size: 12px; color: #484f58;">
678
+ Ctrl+S to save
679
+ </div>
680
+ </div>
681
+ </div>
682
+
462
683
  <div class="sidebar">
684
+ <!-- Files Section -->
685
+ <div class="sidebar-section">
686
+ <div class="sidebar-header" onclick="toggleSection('files')">
687
+ <h2>Files</h2>
688
+ </div>
689
+ <div class="breadcrumb" id="file-breadcrumb">
690
+ <span class="breadcrumb-item" onclick="navigateToPath('')">~</span>
691
+ </div>
692
+ <div class="sidebar-content" id="files-content" style="max-height: 300px;">
693
+ <div class="file-tree" id="file-tree">
694
+ <div class="empty-state">Loading...</div>
695
+ </div>
696
+ </div>
697
+ </div>
698
+
463
699
  <!-- Sessions Section -->
464
700
  <div class="sidebar-section">
465
701
  <div class="sidebar-header" onclick="toggleSection('sessions')">
@@ -508,6 +744,7 @@
508
744
  <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
509
745
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
510
746
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
747
+ <script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js"></script>
511
748
  <script>
512
749
  const terminalContainer = document.getElementById('terminal');
513
750
  const statusDot = document.getElementById('status-dot');
@@ -524,6 +761,309 @@
524
761
  let sessions = [];
525
762
  let currentSessionId = localStorage.getItem('devtunnel-session-id');
526
763
 
764
+ // Editor state
765
+ let monacoEditor = null;
766
+ let openFiles = new Map(); // path -> { content, originalContent, model }
767
+ let activeFilePath = null;
768
+ let currentBrowsePath = '';
769
+
770
+ // Initialize Monaco Editor
771
+ require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs' }});
772
+ require(['vs/editor/editor.main'], function() {
773
+ monaco.editor.defineTheme('github-dark', {
774
+ base: 'vs-dark',
775
+ inherit: true,
776
+ rules: [],
777
+ colors: {
778
+ 'editor.background': '#0d1117',
779
+ 'editor.foreground': '#c9d1d9',
780
+ 'editorCursor.foreground': '#58a6ff',
781
+ 'editor.lineHighlightBackground': '#161b22',
782
+ 'editorLineNumber.foreground': '#6e7681',
783
+ 'editor.selectionBackground': '#264f78',
784
+ 'editorIndentGuide.background': '#21262d',
785
+ }
786
+ });
787
+
788
+ monacoEditor = monaco.editor.create(document.getElementById('monaco-editor'), {
789
+ value: '',
790
+ language: 'plaintext',
791
+ theme: 'github-dark',
792
+ fontSize: 14,
793
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
794
+ minimap: { enabled: false },
795
+ automaticLayout: true,
796
+ scrollBeyondLastLine: false,
797
+ wordWrap: 'on',
798
+ tabSize: 2,
799
+ });
800
+
801
+ // Ctrl+S to save
802
+ monacoEditor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
803
+ if (activeFilePath) saveFile(activeFilePath);
804
+ });
805
+
806
+ // Track changes
807
+ monacoEditor.onDidChangeModelContent(() => {
808
+ if (activeFilePath && openFiles.has(activeFilePath)) {
809
+ const file = openFiles.get(activeFilePath);
810
+ file.content = monacoEditor.getValue();
811
+ renderEditorTabs();
812
+ }
813
+ });
814
+
815
+ // Hide editor initially
816
+ document.getElementById('monaco-editor').style.display = 'none';
817
+ });
818
+
819
+ // Tab switching
820
+ function switchTab(tab) {
821
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
822
+ document.querySelector(`.tab[data-tab="${tab}"]`).classList.add('active');
823
+
824
+ const terminalContainer = document.getElementById('terminal-container');
825
+ const editorContainer = document.getElementById('editor-container');
826
+
827
+ if (tab === 'terminal') {
828
+ terminalContainer.classList.remove('hidden');
829
+ editorContainer.classList.remove('active');
830
+ setTimeout(() => fitAddon.fit(), 0);
831
+ term.focus();
832
+ } else {
833
+ terminalContainer.classList.add('hidden');
834
+ editorContainer.classList.add('active');
835
+ if (monacoEditor) monacoEditor.focus();
836
+ }
837
+ }
838
+
839
+ // File browser functions
840
+ async function loadFiles(path = '') {
841
+ try {
842
+ const res = await fetch(`/api/files?path=${encodeURIComponent(path)}`);
843
+ const data = await res.json();
844
+
845
+ if (data.error) {
846
+ showToast(data.error, 'error');
847
+ return;
848
+ }
849
+
850
+ currentBrowsePath = path;
851
+ renderBreadcrumb(path);
852
+ renderFileTree(data.items || []);
853
+ } catch (err) {
854
+ showToast('Failed to load files', 'error');
855
+ }
856
+ }
857
+
858
+ function renderBreadcrumb(path) {
859
+ const container = document.getElementById('file-breadcrumb');
860
+ const parts = path ? path.split('/').filter(Boolean) : [];
861
+
862
+ let html = '<span class="breadcrumb-item" onclick="navigateToPath(\'\')">~</span>';
863
+
864
+ let currentPath = '';
865
+ parts.forEach((part, i) => {
866
+ currentPath += (currentPath ? '/' : '') + part;
867
+ const p = currentPath;
868
+ html += `<span class="breadcrumb-sep">/</span>`;
869
+ html += `<span class="breadcrumb-item" onclick="navigateToPath('${p}')">${part}</span>`;
870
+ });
871
+
872
+ container.innerHTML = html;
873
+ }
874
+
875
+ let currentFileItems = []; // Store current items for click handling
876
+
877
+ function renderFileTree(items) {
878
+ const container = document.getElementById('file-tree');
879
+ currentFileItems = items;
880
+
881
+ if (items.length === 0) {
882
+ container.innerHTML = '<div class="empty-state">Empty directory</div>';
883
+ return;
884
+ }
885
+
886
+ container.innerHTML = items.map((item, index) => `
887
+ <div class="file-item ${item.isDirectory ? 'directory' : 'file'} ${activeFilePath === item.path ? 'active' : ''}"
888
+ data-index="${index}">
889
+ <span class="file-item-icon">${item.isDirectory ? '&#128193;' : '&#128196;'}</span>
890
+ <span class="file-item-name">${item.name}</span>
891
+ </div>
892
+ `).join('');
893
+ }
894
+
895
+ // Event delegation for file tree clicks
896
+ document.getElementById('file-tree').addEventListener('click', (e) => {
897
+ const fileItem = e.target.closest('.file-item');
898
+ if (!fileItem) return;
899
+
900
+ const index = parseInt(fileItem.dataset.index);
901
+ const item = currentFileItems[index];
902
+ if (!item) return;
903
+
904
+ if (item.isDirectory) {
905
+ navigateToPath(item.path);
906
+ } else {
907
+ openFile(item.path);
908
+ }
909
+ });
910
+
911
+ function navigateToPath(path) {
912
+ loadFiles(path);
913
+ }
914
+
915
+ // File operations
916
+ async function openFile(path) {
917
+ // Switch to editor tab
918
+ switchTab('editor');
919
+
920
+ // Check if already open
921
+ if (openFiles.has(path)) {
922
+ activateFile(path);
923
+ return;
924
+ }
925
+
926
+ try {
927
+ const res = await fetch(`/api/files/read?path=${encodeURIComponent(path)}`);
928
+ const data = await res.json();
929
+
930
+ if (data.error) {
931
+ showToast(data.error, 'error');
932
+ return;
933
+ }
934
+
935
+ // Detect language
936
+ const ext = path.split('.').pop().toLowerCase();
937
+ const langMap = {
938
+ js: 'javascript', ts: 'typescript', jsx: 'javascript', tsx: 'typescript',
939
+ py: 'python', rb: 'ruby', go: 'go', rs: 'rust', java: 'java',
940
+ c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp',
941
+ html: 'html', css: 'css', scss: 'scss', less: 'less',
942
+ json: 'json', xml: 'xml', yaml: 'yaml', yml: 'yaml',
943
+ md: 'markdown', sql: 'sql', sh: 'shell', bash: 'shell',
944
+ dockerfile: 'dockerfile', makefile: 'makefile'
945
+ };
946
+ const language = langMap[ext] || 'plaintext';
947
+
948
+ // Create model
949
+ const model = monaco.editor.createModel(data.content, language);
950
+
951
+ openFiles.set(path, {
952
+ content: data.content,
953
+ originalContent: data.content,
954
+ model,
955
+ language
956
+ });
957
+
958
+ activateFile(path);
959
+ showToast(`Opened ${path.split('/').pop()}`);
960
+ } catch (err) {
961
+ showToast('Failed to open file', 'error');
962
+ }
963
+ }
964
+
965
+ function activateFile(path) {
966
+ activeFilePath = path;
967
+ const file = openFiles.get(path);
968
+
969
+ if (file && monacoEditor) {
970
+ monacoEditor.setModel(file.model);
971
+ document.getElementById('monaco-editor').style.display = 'block';
972
+ document.getElementById('editor-empty').style.display = 'none';
973
+ }
974
+
975
+ renderEditorTabs();
976
+ // Re-render file tree to update active state
977
+ if (currentFileItems.length > 0) {
978
+ renderFileTree(currentFileItems);
979
+ }
980
+ }
981
+
982
+ function renderEditorTabs() {
983
+ const container = document.getElementById('editor-tabs');
984
+
985
+ if (openFiles.size === 0) {
986
+ container.innerHTML = '';
987
+ document.getElementById('monaco-editor').style.display = 'none';
988
+ document.getElementById('editor-empty').style.display = 'flex';
989
+ return;
990
+ }
991
+
992
+ container.innerHTML = Array.from(openFiles.entries()).map(([path, file]) => {
993
+ const name = path.split('/').pop();
994
+ const isModified = file.content !== file.originalContent;
995
+ const isActive = path === activeFilePath;
996
+
997
+ return `
998
+ <div class="editor-tab ${isActive ? 'active' : ''} ${isModified ? 'modified' : ''}" onclick="activateFile('${path}')">
999
+ <span>${name}</span>
1000
+ <span class="editor-tab-close" onclick="event.stopPropagation(); closeFile('${path}')">x</span>
1001
+ </div>
1002
+ `;
1003
+ }).join('');
1004
+ }
1005
+
1006
+ async function saveFile(path) {
1007
+ const file = openFiles.get(path);
1008
+ if (!file) return;
1009
+
1010
+ try {
1011
+ const res = await fetch('/api/files/write', {
1012
+ method: 'POST',
1013
+ headers: { 'Content-Type': 'application/json' },
1014
+ body: JSON.stringify({ path, content: file.content })
1015
+ });
1016
+
1017
+ const data = await res.json();
1018
+
1019
+ if (data.error) {
1020
+ showToast(data.error, 'error');
1021
+ return;
1022
+ }
1023
+
1024
+ file.originalContent = file.content;
1025
+ renderEditorTabs();
1026
+ showToast(`Saved ${path.split('/').pop()}`);
1027
+ } catch (err) {
1028
+ showToast('Failed to save file', 'error');
1029
+ }
1030
+ }
1031
+
1032
+ function closeFile(path) {
1033
+ const file = openFiles.get(path);
1034
+ if (!file) return;
1035
+
1036
+ // Check for unsaved changes
1037
+ if (file.content !== file.originalContent) {
1038
+ if (!confirm(`${path.split('/').pop()} has unsaved changes. Close anyway?`)) {
1039
+ return;
1040
+ }
1041
+ }
1042
+
1043
+ file.model.dispose();
1044
+ openFiles.delete(path);
1045
+
1046
+ if (activeFilePath === path) {
1047
+ const remaining = Array.from(openFiles.keys());
1048
+ if (remaining.length > 0) {
1049
+ activateFile(remaining[remaining.length - 1]);
1050
+ } else {
1051
+ activeFilePath = null;
1052
+ renderEditorTabs();
1053
+ }
1054
+ } else {
1055
+ renderEditorTabs();
1056
+ }
1057
+ }
1058
+
1059
+ // Export functions
1060
+ window.switchTab = switchTab;
1061
+ window.navigateToPath = navigateToPath;
1062
+ window.openFile = openFile;
1063
+ window.activateFile = activateFile;
1064
+ window.closeFile = closeFile;
1065
+ window.saveFile = saveFile;
1066
+
527
1067
  // Initialize xterm.js
528
1068
  const term = new Terminal({
529
1069
  cursorBlink: true,
@@ -809,6 +1349,7 @@
809
1349
 
810
1350
  connect();
811
1351
  term.focus();
1352
+ loadFiles(); // Load initial file list
812
1353
  </script>
813
1354
  </body>
814
1355
  </html>
package/server.js CHANGED
@@ -4,6 +4,7 @@ const WebSocket = require('ws');
4
4
  const pty = require('@homebridge/node-pty-prebuilt-multiarch');
5
5
  const path = require('path');
6
6
  const os = require('os');
7
+ const fs = require('fs').promises;
7
8
  const { spawn } = require('child_process');
8
9
  const crypto = require('crypto');
9
10
 
@@ -364,6 +365,144 @@ wss.on('connection', (ws) => {
364
365
  });
365
366
  });
366
367
 
368
+ // File System API
369
+ const WORKSPACE_ROOT = process.env.WORKSPACE || os.homedir();
370
+
371
+ // Get file/directory listing
372
+ app.get('/api/files', async (req, res) => {
373
+ try {
374
+ const requestedPath = req.query.path || '';
375
+ const fullPath = path.join(WORKSPACE_ROOT, requestedPath);
376
+
377
+ // Security: ensure path is within workspace
378
+ const normalizedPath = path.normalize(fullPath);
379
+ if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
380
+ return res.status(403).json({ error: 'Access denied' });
381
+ }
382
+
383
+ const stats = await fs.stat(normalizedPath);
384
+
385
+ if (stats.isDirectory()) {
386
+ const entries = await fs.readdir(normalizedPath, { withFileTypes: true });
387
+ const items = await Promise.all(
388
+ entries
389
+ .filter(entry => !entry.name.startsWith('.')) // Hide hidden files
390
+ .map(async (entry) => {
391
+ const itemPath = path.join(normalizedPath, entry.name);
392
+ try {
393
+ const itemStats = await fs.stat(itemPath);
394
+ return {
395
+ name: entry.name,
396
+ path: path.join(requestedPath, entry.name),
397
+ isDirectory: entry.isDirectory(),
398
+ size: itemStats.size,
399
+ modified: itemStats.mtime
400
+ };
401
+ } catch {
402
+ return null;
403
+ }
404
+ })
405
+ );
406
+
407
+ // Sort: directories first, then files
408
+ const validItems = items.filter(Boolean).sort((a, b) => {
409
+ if (a.isDirectory !== b.isDirectory) {
410
+ return a.isDirectory ? -1 : 1;
411
+ }
412
+ return a.name.localeCompare(b.name);
413
+ });
414
+
415
+ res.json({
416
+ path: requestedPath,
417
+ items: validItems
418
+ });
419
+ } else {
420
+ res.json({
421
+ path: requestedPath,
422
+ isFile: true,
423
+ size: stats.size,
424
+ modified: stats.mtime
425
+ });
426
+ }
427
+ } catch (err) {
428
+ if (err.code === 'ENOENT') {
429
+ res.status(404).json({ error: 'Path not found' });
430
+ } else {
431
+ res.status(500).json({ error: err.message });
432
+ }
433
+ }
434
+ });
435
+
436
+ // Read file content
437
+ app.get('/api/files/read', async (req, res) => {
438
+ try {
439
+ const requestedPath = req.query.path;
440
+ if (!requestedPath) {
441
+ return res.status(400).json({ error: 'Path required' });
442
+ }
443
+
444
+ const fullPath = path.join(WORKSPACE_ROOT, requestedPath);
445
+ const normalizedPath = path.normalize(fullPath);
446
+
447
+ if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
448
+ return res.status(403).json({ error: 'Access denied' });
449
+ }
450
+
451
+ const stats = await fs.stat(normalizedPath);
452
+
453
+ // Limit file size (5MB)
454
+ if (stats.size > 5 * 1024 * 1024) {
455
+ return res.status(413).json({ error: 'File too large' });
456
+ }
457
+
458
+ const content = await fs.readFile(normalizedPath, 'utf-8');
459
+ res.json({
460
+ path: requestedPath,
461
+ content,
462
+ size: stats.size,
463
+ modified: stats.mtime
464
+ });
465
+ } catch (err) {
466
+ if (err.code === 'ENOENT') {
467
+ res.status(404).json({ error: 'File not found' });
468
+ } else {
469
+ res.status(500).json({ error: err.message });
470
+ }
471
+ }
472
+ });
473
+
474
+ // Write file content
475
+ app.post('/api/files/write', async (req, res) => {
476
+ try {
477
+ const { path: filePath, content } = req.body;
478
+
479
+ if (!filePath) {
480
+ return res.status(400).json({ error: 'Path required' });
481
+ }
482
+
483
+ const fullPath = path.join(WORKSPACE_ROOT, filePath);
484
+ const normalizedPath = path.normalize(fullPath);
485
+
486
+ if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
487
+ return res.status(403).json({ error: 'Access denied' });
488
+ }
489
+
490
+ // Ensure parent directory exists
491
+ await fs.mkdir(path.dirname(normalizedPath), { recursive: true });
492
+
493
+ await fs.writeFile(normalizedPath, content, 'utf-8');
494
+ const stats = await fs.stat(normalizedPath);
495
+
496
+ res.json({
497
+ path: filePath,
498
+ size: stats.size,
499
+ modified: stats.mtime
500
+ });
501
+ } catch (err) {
502
+ res.status(500).json({ error: err.message });
503
+ }
504
+ });
505
+
367
506
  // REST API
368
507
  app.get('/api/sessions', (req, res) => {
369
508
  const sessionList = Array.from(sessions.values()).map(s => ({