cc-agent-ui 0.2.4 → 0.2.6
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 +1 -1
- package/public/index.html +148 -73
- package/server.js +80 -3
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -440,38 +440,50 @@ body {
|
|
|
440
440
|
.flash-ok { animation: flash-g 0.8s ease-out; }
|
|
441
441
|
.flash-err { animation: flash-r 0.8s ease-out; }
|
|
442
442
|
|
|
443
|
-
/* ── File Browser
|
|
444
|
-
|
|
443
|
+
/* ── File Browser Modal ──────────────────────────────────────────────────── */
|
|
444
|
+
.fb-backdrop {
|
|
445
445
|
position: fixed;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
446
|
+
inset: 0;
|
|
447
|
+
background: rgba(0,0,0,0.7);
|
|
448
|
+
z-index: 1000;
|
|
449
|
+
display: flex;
|
|
450
|
+
align-items: center;
|
|
451
|
+
justify-content: center;
|
|
452
|
+
}
|
|
453
|
+
.fb-backdrop.hidden { display: none; }
|
|
454
|
+
|
|
455
|
+
#fb-modal {
|
|
456
|
+
background: #0d1117;
|
|
457
|
+
border: 1px solid #2a2d3a;
|
|
458
|
+
border-radius: 8px;
|
|
459
|
+
width: 80vw;
|
|
460
|
+
max-width: 900px;
|
|
461
|
+
height: 80vh;
|
|
450
462
|
display: flex;
|
|
451
463
|
flex-direction: column;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
transition: transform 0.22s cubic-bezier(.4,0,.2,1);
|
|
464
|
+
overflow: hidden;
|
|
465
|
+
box-shadow: 0 24px 64px rgba(0,0,0,0.7);
|
|
455
466
|
}
|
|
456
|
-
#filebrowser.open { transform: translateX(0); }
|
|
457
467
|
|
|
458
|
-
#fb-
|
|
468
|
+
#fb-modal-header {
|
|
459
469
|
display: flex;
|
|
460
470
|
align-items: center;
|
|
461
471
|
padding: 0 14px;
|
|
462
|
-
height:
|
|
472
|
+
height: 42px;
|
|
463
473
|
border-bottom: 1px solid var(--border);
|
|
464
|
-
gap:
|
|
474
|
+
gap: 10px;
|
|
465
475
|
flex-shrink: 0;
|
|
476
|
+
background: rgba(0,0,0,0.3);
|
|
466
477
|
}
|
|
467
478
|
#fb-path {
|
|
468
479
|
flex: 1;
|
|
469
|
-
font-size:
|
|
480
|
+
font-size: 11px;
|
|
470
481
|
color: var(--cyan);
|
|
471
482
|
overflow: hidden;
|
|
472
483
|
text-overflow: ellipsis;
|
|
473
484
|
white-space: nowrap;
|
|
474
485
|
cursor: default;
|
|
486
|
+
font-family: var(--font);
|
|
475
487
|
}
|
|
476
488
|
#fb-close {
|
|
477
489
|
background: transparent;
|
|
@@ -555,7 +567,6 @@ body {
|
|
|
555
567
|
transition: transform 0.22s cubic-bezier(.4,0,.2,1);
|
|
556
568
|
}
|
|
557
569
|
#jobpanel.open { transform: translateX(0); }
|
|
558
|
-
#jobpanel.open ~ #filebrowser { right: 600px; }
|
|
559
570
|
|
|
560
571
|
#jp-topbar {
|
|
561
572
|
display: flex; align-items: center;
|
|
@@ -635,14 +646,14 @@ body {
|
|
|
635
646
|
#jp-task strong { color: var(--text); display: block; margin-bottom: 4px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
636
647
|
|
|
637
648
|
/* Clickable file paths in terminal */
|
|
638
|
-
.
|
|
649
|
+
.file-link {
|
|
639
650
|
color: var(--orange);
|
|
640
651
|
text-decoration: underline;
|
|
641
652
|
text-decoration-color: rgba(209,154,102,0.4);
|
|
642
653
|
cursor: pointer;
|
|
643
654
|
border-radius: 2px;
|
|
644
655
|
}
|
|
645
|
-
.
|
|
656
|
+
.file-link:hover {
|
|
646
657
|
background: rgba(209,154,102,0.1);
|
|
647
658
|
text-decoration-color: var(--orange);
|
|
648
659
|
}
|
|
@@ -801,14 +812,19 @@ body {
|
|
|
801
812
|
</div>
|
|
802
813
|
</div>
|
|
803
814
|
|
|
804
|
-
<!-- File Browser
|
|
805
|
-
<div id="
|
|
806
|
-
<div id="fb-
|
|
807
|
-
<
|
|
808
|
-
|
|
809
|
-
|
|
815
|
+
<!-- File Browser Modal -->
|
|
816
|
+
<div id="fb-backdrop" class="fb-backdrop hidden">
|
|
817
|
+
<div id="fb-modal">
|
|
818
|
+
<div id="fb-modal-header">
|
|
819
|
+
<span style="color:var(--dim);font-size:12px;flex-shrink:0">📁</span>
|
|
820
|
+
<span id="fb-path">—</span>
|
|
821
|
+
<div style="display:flex;gap:6px;flex-shrink:0">
|
|
822
|
+
<button id="fb-back" onclick="fbGoBack()">← back</button>
|
|
823
|
+
<button id="fb-close" onclick="fbClose()">✕</button>
|
|
824
|
+
</div>
|
|
825
|
+
</div>
|
|
826
|
+
<div id="fb-body"></div>
|
|
810
827
|
</div>
|
|
811
|
-
<div id="fb-body"></div>
|
|
812
828
|
</div>
|
|
813
829
|
|
|
814
830
|
<script>
|
|
@@ -1355,15 +1371,7 @@ async function jpSendMessage() {
|
|
|
1355
1371
|
headers: { 'Content-Type': 'application/json' },
|
|
1356
1372
|
body: JSON.stringify({ id: jpCurrentId, action: 'message', message: text }),
|
|
1357
1373
|
});
|
|
1358
|
-
|
|
1359
|
-
const d = document.createElement('div');
|
|
1360
|
-
d.className = 'tl tl-head';
|
|
1361
|
-
d.textContent = `[you] ${text}`;
|
|
1362
|
-
jpLog.appendChild(d);
|
|
1363
|
-
jpLog.scrollTop = jpLog.scrollHeight;
|
|
1364
|
-
// Also echo to card terminal
|
|
1365
|
-
const entry = jobs[jpCurrentId];
|
|
1366
|
-
if (entry?.logEl) appendLines(entry.logEl, [`[you] ${text}`], true);
|
|
1374
|
+
await r.json();
|
|
1367
1375
|
} catch (e) {
|
|
1368
1376
|
const d = document.createElement('div');
|
|
1369
1377
|
d.className = 'tl tl-err';
|
|
@@ -1481,24 +1489,25 @@ async function jobAction(id, action) {
|
|
|
1481
1489
|
// Wire card clicks to open job panel
|
|
1482
1490
|
function wireCardClick(card, id) {
|
|
1483
1491
|
card.querySelector('.card-hdr').addEventListener('click', (e) => {
|
|
1484
|
-
if (e.target.closest('.
|
|
1492
|
+
if (e.target.closest('.file-link')) return; // don't interfere with file links
|
|
1485
1493
|
jpOpen(id);
|
|
1486
1494
|
});
|
|
1487
1495
|
}
|
|
1488
1496
|
|
|
1489
|
-
// ── File Browser
|
|
1490
|
-
const
|
|
1497
|
+
// ── File Browser Modal ─────────────────────────────────────────────────────
|
|
1498
|
+
const fbBackdrop = $('fb-backdrop');
|
|
1491
1499
|
const fbBody = $('fb-body');
|
|
1492
1500
|
const fbPathEl = $('fb-path');
|
|
1493
1501
|
let fbHistory = [];
|
|
1494
1502
|
|
|
1495
1503
|
function fbOpen(p) {
|
|
1496
|
-
|
|
1504
|
+
fbBackdrop.classList.remove('hidden');
|
|
1497
1505
|
fbNavigate(p);
|
|
1498
1506
|
}
|
|
1499
1507
|
|
|
1500
1508
|
function fbClose() {
|
|
1501
|
-
|
|
1509
|
+
fbBackdrop.classList.add('hidden');
|
|
1510
|
+
fbHistory = [];
|
|
1502
1511
|
}
|
|
1503
1512
|
|
|
1504
1513
|
function fbGoBack() {
|
|
@@ -1508,19 +1517,30 @@ function fbGoBack() {
|
|
|
1508
1517
|
}
|
|
1509
1518
|
}
|
|
1510
1519
|
|
|
1520
|
+
function fbParentDir(p) {
|
|
1521
|
+
const trimmed = p.replace(/\/+$/, '');
|
|
1522
|
+
if (!trimmed || trimmed === '/') return null;
|
|
1523
|
+
const parent = trimmed.replace(/\/[^/]+$/, '') || '/';
|
|
1524
|
+
return parent;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1511
1527
|
async function fbNavigate(p, fromBack) {
|
|
1512
1528
|
if (!fromBack) fbHistory.push(p);
|
|
1513
1529
|
fbPathEl.textContent = p;
|
|
1514
1530
|
fbBody.innerHTML = '<div style="padding:14px;color:var(--dim)">loading…</div>';
|
|
1515
1531
|
try {
|
|
1516
|
-
const
|
|
1517
|
-
if (!
|
|
1518
|
-
const
|
|
1519
|
-
if (
|
|
1520
|
-
|
|
1521
|
-
|
|
1532
|
+
const statRes = await fetch(`/api/fs/stat?path=${encodeURIComponent(p)}`);
|
|
1533
|
+
if (!statRes.ok) throw new Error(await statRes.text());
|
|
1534
|
+
const stat = await statRes.json();
|
|
1535
|
+
if (!stat.exists) throw new Error('not found: ' + p);
|
|
1536
|
+
|
|
1537
|
+
if (stat.type === 'dir') {
|
|
1538
|
+
const lsRes = await fetch(`/api/fs/ls?path=${encodeURIComponent(p)}`);
|
|
1539
|
+
if (!lsRes.ok) throw new Error(await lsRes.text());
|
|
1540
|
+
const data = await lsRes.json();
|
|
1541
|
+
renderDir(p, data.entries);
|
|
1522
1542
|
} else {
|
|
1523
|
-
renderFile(p
|
|
1543
|
+
renderFile(p);
|
|
1524
1544
|
}
|
|
1525
1545
|
} catch (e) {
|
|
1526
1546
|
fbBody.innerHTML = `<div style="padding:14px;color:var(--red)">${escHtml(e.message)}</div>`;
|
|
@@ -1537,73 +1557,128 @@ function fmtSize(n) {
|
|
|
1537
1557
|
function fileIcon(name, type) {
|
|
1538
1558
|
if (type === 'dir') return '📁';
|
|
1539
1559
|
const ext = name.split('.').pop().toLowerCase();
|
|
1540
|
-
if (['js','ts','tsx','jsx','py','go','rs','sh'].includes(ext)) return '📄';
|
|
1560
|
+
if (['js','ts','tsx','jsx','py','go','rs','sh','clj','cljs','sql'].includes(ext)) return '📄';
|
|
1541
1561
|
if (['png','jpg','jpeg','gif','svg','webp'].includes(ext)) return '🖼';
|
|
1542
1562
|
if (['mp4','webm','mov'].includes(ext)) return '🎬';
|
|
1543
1563
|
if (['mp3','wav','ogg'].includes(ext)) return '🎵';
|
|
1544
1564
|
if (ext === 'pdf') return '📋';
|
|
1545
1565
|
if (['md','txt'].includes(ext)) return '📝';
|
|
1546
1566
|
if (ext === 'json') return '{}';
|
|
1567
|
+
if (['yaml','yml'].includes(ext)) return '⚙';
|
|
1547
1568
|
return '📄';
|
|
1548
1569
|
}
|
|
1549
1570
|
|
|
1550
|
-
function renderDir(
|
|
1571
|
+
function renderDir(basePath, entries) {
|
|
1551
1572
|
const frag = document.createDocumentFragment();
|
|
1552
|
-
|
|
1573
|
+
|
|
1574
|
+
// Parent dir entry
|
|
1575
|
+
const parent = fbParentDir(basePath);
|
|
1576
|
+
if (parent) {
|
|
1577
|
+
const up = document.createElement('div');
|
|
1578
|
+
up.className = 'fb-entry';
|
|
1579
|
+
up.innerHTML = `<span class="fb-icon">↑</span><span class="fb-name" style="color:var(--dim)">parent directory</span><span class="fb-size"></span>`;
|
|
1580
|
+
up.addEventListener('click', () => fbNavigate(parent));
|
|
1581
|
+
frag.appendChild(up);
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
for (const e of entries) {
|
|
1585
|
+
const entryPath = basePath.replace(/\/$/, '') + '/' + e.name;
|
|
1553
1586
|
const row = document.createElement('div');
|
|
1554
1587
|
row.className = `fb-entry${e.type === 'dir' ? ' is-dir' : ''}`;
|
|
1555
|
-
row.innerHTML = `<span class="fb-icon">${fileIcon(e.name, e.type)}</span><span class="fb-name">${escHtml(e.name)}</span><span class="fb-size">${fmtSize(e.size)}</span>`;
|
|
1556
|
-
row.addEventListener('click', () => fbNavigate(
|
|
1588
|
+
row.innerHTML = `<span class="fb-icon">${fileIcon(e.name, e.type)}</span><span class="fb-name">${escHtml(e.name + (e.type === 'dir' ? '/' : ''))}</span><span class="fb-size">${fmtSize(e.size)}</span>`;
|
|
1589
|
+
row.addEventListener('click', () => fbNavigate(entryPath));
|
|
1557
1590
|
frag.appendChild(row);
|
|
1558
1591
|
}
|
|
1592
|
+
|
|
1559
1593
|
fbBody.innerHTML = '';
|
|
1560
|
-
if (
|
|
1594
|
+
if (entries.length === 0 && !parent) {
|
|
1561
1595
|
fbBody.innerHTML = '<div style="padding:14px;color:var(--dim)">empty directory</div>';
|
|
1562
1596
|
} else {
|
|
1563
1597
|
fbBody.appendChild(frag);
|
|
1564
1598
|
}
|
|
1565
1599
|
}
|
|
1566
1600
|
|
|
1567
|
-
async function renderFile(p
|
|
1568
|
-
const
|
|
1569
|
-
const
|
|
1570
|
-
const isVideo = ct.startsWith('video/');
|
|
1571
|
-
const isAudio = ct.startsWith('audio/');
|
|
1572
|
-
const url = `/api/browse?path=${encodeURIComponent(p)}`;
|
|
1601
|
+
async function renderFile(p) {
|
|
1602
|
+
const ext = p.split('.').pop().toLowerCase();
|
|
1603
|
+
const rawUrl = `/api/fs/raw?path=${encodeURIComponent(p)}`;
|
|
1573
1604
|
const div = document.createElement('div');
|
|
1574
1605
|
div.id = 'fb-file-view';
|
|
1575
|
-
|
|
1606
|
+
|
|
1607
|
+
const imageExts = ['png','jpg','jpeg','gif','svg','webp'];
|
|
1608
|
+
const videoExts = ['mp4','webm','mov'];
|
|
1609
|
+
const audioExts = ['mp3','wav','ogg'];
|
|
1610
|
+
const textExts = ['js','ts','tsx','jsx','py','go','rs','md','json','yaml','yml',
|
|
1611
|
+
'sh','bash','html','css','txt','log','clj','cljs','sql','toml',
|
|
1612
|
+
'env','gitignore','dockerfile','makefile','lock'];
|
|
1613
|
+
|
|
1614
|
+
if (imageExts.includes(ext)) {
|
|
1576
1615
|
div.className = 'media-view';
|
|
1577
|
-
div.innerHTML = `<img src="${
|
|
1578
|
-
|
|
1616
|
+
div.innerHTML = `<img src="${rawUrl}" alt="${escHtml(p)}">`;
|
|
1617
|
+
fbBody.innerHTML = '';
|
|
1618
|
+
fbBody.appendChild(div);
|
|
1619
|
+
} else if (videoExts.includes(ext)) {
|
|
1579
1620
|
div.className = 'media-view';
|
|
1580
|
-
div.innerHTML = `<video controls src="${
|
|
1581
|
-
|
|
1621
|
+
div.innerHTML = `<video controls src="${rawUrl}"></video>`;
|
|
1622
|
+
fbBody.innerHTML = '';
|
|
1623
|
+
fbBody.appendChild(div);
|
|
1624
|
+
} else if (audioExts.includes(ext)) {
|
|
1582
1625
|
div.className = 'media-view';
|
|
1583
|
-
div.innerHTML = `<audio controls src="${
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1626
|
+
div.innerHTML = `<audio controls src="${rawUrl}"></audio>`;
|
|
1627
|
+
fbBody.innerHTML = '';
|
|
1628
|
+
fbBody.appendChild(div);
|
|
1629
|
+
} else if (textExts.includes(ext) || !ext || p.split('/').pop().indexOf('.') === -1) {
|
|
1630
|
+
div.style.cssText = 'padding:12px 14px;font-size:11px;line-height:1.65;white-space:pre-wrap;word-break:break-all;color:var(--text);overflow:auto;flex:1';
|
|
1631
|
+
div.textContent = 'loading…';
|
|
1632
|
+
fbBody.innerHTML = '';
|
|
1633
|
+
fbBody.appendChild(div);
|
|
1634
|
+
try {
|
|
1635
|
+
const r = await fetch(`/api/fs/cat?path=${encodeURIComponent(p)}`);
|
|
1636
|
+
if (!r.ok) { div.style.color = 'var(--red)'; div.textContent = await r.text(); return; }
|
|
1637
|
+
const data = await r.json();
|
|
1638
|
+
div.textContent = data.content;
|
|
1639
|
+
} catch (e) {
|
|
1640
|
+
div.style.color = 'var(--red)';
|
|
1641
|
+
div.textContent = e.message;
|
|
1642
|
+
}
|
|
1587
1643
|
} else {
|
|
1588
|
-
div.innerHTML = `<div style="color:var(--dim);padding:14px">Binary file — <a href="${
|
|
1644
|
+
div.innerHTML = `<div style="color:var(--dim);padding:14px">Binary file — <a href="${rawUrl}" download style="color:var(--cyan)">download</a></div>`;
|
|
1645
|
+
fbBody.innerHTML = '';
|
|
1646
|
+
fbBody.appendChild(div);
|
|
1589
1647
|
}
|
|
1590
|
-
fbBody.innerHTML = '';
|
|
1591
|
-
fbBody.appendChild(div);
|
|
1592
1648
|
}
|
|
1593
1649
|
|
|
1650
|
+
// Close modal on backdrop click or ESC
|
|
1651
|
+
document.addEventListener('keydown', e => {
|
|
1652
|
+
if (e.key === 'Escape' && !fbBackdrop.classList.contains('hidden')) fbClose();
|
|
1653
|
+
});
|
|
1654
|
+
$('fb-backdrop').addEventListener('click', e => {
|
|
1655
|
+
if (e.target === $('fb-backdrop')) fbClose();
|
|
1656
|
+
});
|
|
1657
|
+
|
|
1594
1658
|
// ── File path detection in terminal lines ─────────────────────────────────
|
|
1595
|
-
|
|
1659
|
+
function escAttr(s) {
|
|
1660
|
+
return (s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''');
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Delegated click handler for .file-link spans
|
|
1664
|
+
document.addEventListener('click', e => {
|
|
1665
|
+
const link = e.target.closest('.file-link');
|
|
1666
|
+
if (link) {
|
|
1667
|
+
e.stopPropagation();
|
|
1668
|
+
fbOpen(link.dataset.path);
|
|
1669
|
+
}
|
|
1670
|
+
});
|
|
1596
1671
|
|
|
1597
1672
|
function linkifyPaths(text) {
|
|
1598
|
-
|
|
1599
|
-
// Split on path patterns, escape non-path segments, wrap paths in spans
|
|
1673
|
+
const PATH_RE = /(\/(?:Users|home|tmp|workspace|var\/folders)[^\s"'`,;()[\]<>]{3,}|~\/[^\s"'`,;()[\]<>]+)/g;
|
|
1600
1674
|
const parts = [];
|
|
1601
1675
|
let last = 0;
|
|
1602
1676
|
let m;
|
|
1603
1677
|
PATH_RE.lastIndex = 0;
|
|
1604
1678
|
while ((m = PATH_RE.exec(text)) !== null) {
|
|
1605
1679
|
if (m.index > last) parts.push(escHtml(text.slice(last, m.index)));
|
|
1606
|
-
|
|
1680
|
+
const p = m[0];
|
|
1681
|
+
parts.push(`<span class="file-link" data-path="${escAttr(p)}">${escHtml(p)}</span>`);
|
|
1607
1682
|
last = m.index + m[0].length;
|
|
1608
1683
|
}
|
|
1609
1684
|
if (last < text.length) parts.push(escHtml(text.slice(last)));
|
package/server.js
CHANGED
|
@@ -154,10 +154,24 @@ function mimeFor(ext) {
|
|
|
154
154
|
mp4:'video/mp4', webm:'video/webm', mov:'video/quicktime',
|
|
155
155
|
mp3:'audio/mpeg', wav:'audio/wav', ogg:'audio/ogg',
|
|
156
156
|
pdf:'application/pdf',
|
|
157
|
+
clj:'text/x-clojure', cljs:'text/x-clojure', sql:'text/x-sql',
|
|
158
|
+
log:'text/plain', env:'text/plain', toml:'text/x-toml',
|
|
157
159
|
};
|
|
158
160
|
return map[ext] || 'application/octet-stream';
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
// Security: only allow paths under approved roots
|
|
164
|
+
const ALLOWED_ROOTS = [os.homedir(), '/tmp', '/workspace'];
|
|
165
|
+
|
|
166
|
+
function isAllowed(p) {
|
|
167
|
+
const resolved = p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
|
|
168
|
+
return ALLOWED_ROOTS.some(root => resolved === root || resolved.startsWith(root + '/'));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolvePath(p) {
|
|
172
|
+
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : path.resolve(p);
|
|
173
|
+
}
|
|
174
|
+
|
|
161
175
|
// ── HTTP server ────────────────────────────────────────────────────────────
|
|
162
176
|
const server = http.createServer((req, res) => {
|
|
163
177
|
const url = new URL(req.url, `http://localhost`);
|
|
@@ -172,8 +186,8 @@ const server = http.createServer((req, res) => {
|
|
|
172
186
|
// List directory or read file
|
|
173
187
|
const p = url.searchParams.get('path');
|
|
174
188
|
if (!p) { res.writeHead(400); res.end('missing path'); return; }
|
|
175
|
-
|
|
176
|
-
const resolved =
|
|
189
|
+
if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
190
|
+
const resolved = resolvePath(p);
|
|
177
191
|
try {
|
|
178
192
|
const stat = fs.statSync(resolved);
|
|
179
193
|
if (stat.isDirectory()) {
|
|
@@ -195,6 +209,67 @@ const server = http.createServer((req, res) => {
|
|
|
195
209
|
res.writeHead(404); res.end(e.message);
|
|
196
210
|
}
|
|
197
211
|
|
|
212
|
+
} else if (url.pathname === '/api/fs/stat') {
|
|
213
|
+
const p = url.searchParams.get('path');
|
|
214
|
+
if (!p) { res.writeHead(400); res.end('missing path'); return; }
|
|
215
|
+
if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
216
|
+
const resolved = resolvePath(p);
|
|
217
|
+
try {
|
|
218
|
+
const stat = fs.statSync(resolved);
|
|
219
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
220
|
+
res.end(JSON.stringify({ exists: true, type: stat.isDirectory() ? 'dir' : 'file', size: stat.size }));
|
|
221
|
+
} catch {
|
|
222
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
223
|
+
res.end(JSON.stringify({ exists: false }));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
} else if (url.pathname === '/api/fs/ls') {
|
|
227
|
+
const p = url.searchParams.get('path');
|
|
228
|
+
if (!p) { res.writeHead(400); res.end('missing path'); return; }
|
|
229
|
+
if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
230
|
+
const resolved = resolvePath(p);
|
|
231
|
+
try {
|
|
232
|
+
const entries = fs.readdirSync(resolved, { withFileTypes: true }).map(e => ({
|
|
233
|
+
name: e.name,
|
|
234
|
+
type: e.isDirectory() ? 'dir' : 'file',
|
|
235
|
+
size: e.isFile() ? (() => { try { return fs.statSync(path.join(resolved, e.name)).size; } catch { return 0; } })() : null,
|
|
236
|
+
ext: path.extname(e.name).slice(1).toLowerCase(),
|
|
237
|
+
})).sort((a,b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1));
|
|
238
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
239
|
+
res.end(JSON.stringify({ entries }));
|
|
240
|
+
} catch (e) {
|
|
241
|
+
res.writeHead(404); res.end(e.message);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
} else if (url.pathname === '/api/fs/cat') {
|
|
245
|
+
const p = url.searchParams.get('path');
|
|
246
|
+
if (!p) { res.writeHead(400); res.end('missing path'); return; }
|
|
247
|
+
if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
248
|
+
const resolved = resolvePath(p);
|
|
249
|
+
try {
|
|
250
|
+
const stat = fs.statSync(resolved);
|
|
251
|
+
if (stat.size > 1048576) { res.writeHead(400); res.end('file too large (>1MB)'); return; }
|
|
252
|
+
const content = fs.readFileSync(resolved, 'utf8');
|
|
253
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
254
|
+
res.end(JSON.stringify({ content }));
|
|
255
|
+
} catch (e) {
|
|
256
|
+
res.writeHead(404); res.end(e.message);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
} else if (url.pathname === '/api/fs/raw') {
|
|
260
|
+
const p = url.searchParams.get('path');
|
|
261
|
+
if (!p) { res.writeHead(400); res.end('missing path'); return; }
|
|
262
|
+
if (!isAllowed(p)) { res.writeHead(403); res.end('forbidden'); return; }
|
|
263
|
+
const resolved = resolvePath(p);
|
|
264
|
+
try {
|
|
265
|
+
const ext = path.extname(resolved).slice(1).toLowerCase();
|
|
266
|
+
const mime = mimeFor(ext);
|
|
267
|
+
res.writeHead(200, { 'Content-Type': mime });
|
|
268
|
+
fs.createReadStream(resolved).pipe(res);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
res.writeHead(404); res.end(e.message);
|
|
271
|
+
}
|
|
272
|
+
|
|
198
273
|
} else if (url.pathname === '/api/job/output') {
|
|
199
274
|
// Full output for a job
|
|
200
275
|
const id = url.searchParams.get('id');
|
|
@@ -240,7 +315,9 @@ const server = http.createServer((req, res) => {
|
|
|
240
315
|
await redis.rPush(`cca:job:${id}:input`, message);
|
|
241
316
|
// Echo to output so it's visible in terminal immediately
|
|
242
317
|
const line = `[you] ${message}`;
|
|
243
|
-
await redis.rPush(`cca:job:${id}:output`, line);
|
|
318
|
+
const newLen = await redis.rPush(`cca:job:${id}:output`, line);
|
|
319
|
+
// Advance the output length tracker so the poller doesn't re-broadcast this line
|
|
320
|
+
outputLengths[id] = newLen;
|
|
244
321
|
broadcast({ type: 'job_output', id, lines: [line] });
|
|
245
322
|
}
|
|
246
323
|
}
|