coxpit 5.17.0 → 5.18.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 +2 -1
- package/src/cockpit.ts +189 -16
- package/src/files.ts +131 -0
- package/src/server.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.18.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"dotenv": "^16",
|
|
52
52
|
"drizzle-orm": "^0.38",
|
|
53
53
|
"fastify": "^5",
|
|
54
|
+
"marked": "^14.1.4",
|
|
54
55
|
"node-pty": "^1.1.0",
|
|
55
56
|
"tsx": "^4"
|
|
56
57
|
},
|
package/src/cockpit.ts
CHANGED
|
@@ -136,6 +136,22 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
136
136
|
.term-host .xterm-viewport::-webkit-scrollbar-thumb{background:var(--line-hi);border-radius:4px}
|
|
137
137
|
.term-host .xterm-viewport::-webkit-scrollbar-thumb:hover{background:var(--faint)}
|
|
138
138
|
|
|
139
|
+
/* ── 파일 뷰어 페인(비터미널 탭) ── */
|
|
140
|
+
.view-host{flex:1;min-height:0;min-width:0;display:flex;flex-direction:column;background:var(--bg)}
|
|
141
|
+
.view-bar{display:flex;align-items:center;gap:8px;height:26px;padding:0 8px;background:var(--surface2);border-bottom:1px solid var(--line);font-family:var(--mono);font-size:11px;color:var(--muted);flex:none}
|
|
142
|
+
.view-bar .vp{flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;direction:rtl;text-align:left;color:var(--faint)}
|
|
143
|
+
.view-bar button{font:inherit;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);border:1px solid var(--line-hi);border-radius:5px;padding:1px 8px;cursor:pointer}
|
|
144
|
+
.view-bar button:hover{border-color:var(--brand)}
|
|
145
|
+
.view-bar .prim{color:var(--brand-ink);background:var(--brand);border-color:var(--brand)}
|
|
146
|
+
.view-body{flex:1;min-height:0;overflow:auto;background:var(--bg);position:relative}
|
|
147
|
+
.view-body iframe{width:100%;height:100%;border:0;background:#fff}
|
|
148
|
+
.view-body img{max-width:100%;display:block;margin:0 auto;padding:12px}
|
|
149
|
+
.view-body pre.vtext{margin:0;padding:12px 14px;font-family:var(--mono);font-size:12px;line-height:1.55;color:var(--ink);white-space:pre-wrap;word-break:break-word}
|
|
150
|
+
.view-body textarea.vedit{width:100%;height:100%;box-sizing:border-box;border:0;outline:none;resize:none;padding:12px 14px;font-family:var(--mono);font-size:12.5px;line-height:1.55;color:var(--ink);background:var(--bg)}
|
|
151
|
+
.view-msg{padding:24px;text-align:center;color:var(--faint);font-family:var(--mono);font-size:12px}
|
|
152
|
+
.view-msg a{color:var(--brand)}
|
|
153
|
+
.st.vdoc{background:none;color:var(--faint);width:auto;font-size:11px}
|
|
154
|
+
|
|
139
155
|
.empty{flex:1;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}
|
|
140
156
|
.empty .card{max-width:440px}
|
|
141
157
|
.empty .glyph{font-family:var(--mono);font-size:24px;color:#2c3444;letter-spacing:5px;margin-bottom:14px}
|
|
@@ -326,6 +342,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
326
342
|
<button class="tc-btn" id="splitRow" title="세로 분할 — 포커스 페인을 좌우로" disabled>▐ split</button>
|
|
327
343
|
<button class="tc-btn" id="splitCol" title="가로 분할 — 포커스 페인을 상하로" disabled>▬ split</button>
|
|
328
344
|
<button class="tc-btn session" id="sessionBtn" title="자유 세션(폴더 지정 터미널) 열기">+<span class="b-txt"> Session</span></button>
|
|
345
|
+
<button class="tc-btn" id="fileBtn" title="파일 보기 — md·html·pdf·이미지·텍스트 뷰어(터미널 옆 페인)">▤<span class="b-txt"> File</span></button>
|
|
329
346
|
<button class="tc-btn" id="closeBtn" title="포커스 페인 닫기(탭은 유지)" disabled>×<span class="b-txt"> pane</span></button>
|
|
330
347
|
</div>
|
|
331
348
|
</div>
|
|
@@ -411,6 +428,18 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
411
428
|
</div>
|
|
412
429
|
</div>
|
|
413
430
|
|
|
431
|
+
<div class="modal" id="fpickModal">
|
|
432
|
+
<div class="pick">
|
|
433
|
+
<div class="pick-h"><span class="t">파일 보기</span><button class="x" id="fpClose" title="닫기">×</button></div>
|
|
434
|
+
<div class="pick-path" id="fpPath">…</div>
|
|
435
|
+
<div class="pick-list" id="fpList"></div>
|
|
436
|
+
<div class="pick-f">
|
|
437
|
+
<button class="home" id="fpHome" title="홈으로">⌂ home</button>
|
|
438
|
+
<span style="flex:1;font-size:11px;color:var(--faint)">폴더=이동 · 파일=뷰어로 열기</span>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
414
443
|
<div class="modal" id="secretsModal">
|
|
415
444
|
<div class="pick" style="width:min(520px,92vw)">
|
|
416
445
|
<div class="pick-h"><span class="t">시크릿 (env 주입)</span><button class="x" id="secretsClose" title="닫기">×</button></div>
|
|
@@ -444,6 +473,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
444
473
|
<script src="/vendor/addon-fit.js"></script>
|
|
445
474
|
<script src="/vendor/addon-unicode11.js"></script>
|
|
446
475
|
<script src="/vendor/addon-web-links.js"></script>
|
|
476
|
+
<script src="/vendor/marked.js"></script>
|
|
447
477
|
<script>
|
|
448
478
|
// 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
|
|
449
479
|
// (이전엔 보드로 리다이렉트했지만, 이제 cockpit 을 모바일 대응)
|
|
@@ -576,9 +606,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
576
606
|
var layout = { leaf:true, id:'L0', tab:null }; // 분할 트리 루트
|
|
577
607
|
var focusLeaf = 'L0';
|
|
578
608
|
var leafSeq = 1;
|
|
609
|
+
var viewerSeq = 1; // 뷰어 탭 키(문자열 'v#')
|
|
579
610
|
var MAX_LEAVES = 6;
|
|
580
611
|
var dragRunId = null;
|
|
581
612
|
|
|
613
|
+
// 뷰어 탭은 문자열 키('v1'…), 터미널 탭은 숫자 runId. 이벤트 핸들러에서 강제 숫자화 금지.
|
|
614
|
+
function isViewer(id){ return typeof id==='string' && id.charAt(0)==='v'; }
|
|
615
|
+
function tabIdOf(el){ var v=el.dataset.tab; return (v && v.charAt(0)==='v') ? v : +v; }
|
|
616
|
+
|
|
582
617
|
function newLeafId(){ return 'L'+(leafSeq++); }
|
|
583
618
|
function eachLeaf(node, fn){ if(node.leaf){ fn(node); } else { eachLeaf(node.a,fn); eachLeaf(node.b,fn); } }
|
|
584
619
|
function findLeaf(id, node){ node=node||layout; if(node.leaf) return node.id===id?node:null; return findLeaf(id,node.a)||findLeaf(id,node.b); }
|
|
@@ -609,12 +644,109 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
609
644
|
try{ term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion='11'; }catch(e){}
|
|
610
645
|
// URL 링크 클릭 가능(웹=새 탭, 데스크톱 앱=시스템 브라우저 — setWindowOpenHandler 가 external 로).
|
|
611
646
|
try{ term.loadAddon(new window.WebLinksAddon.WebLinksAddon(function(ev, uri){ window.open(uri, '_blank', 'noopener'); })); }catch(e){}
|
|
647
|
+
// 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
|
|
648
|
+
// ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
|
|
649
|
+
var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
|
|
650
|
+
try{ if(navigator.clipboard&&navigator.clipboard.writeText) navigator.clipboard.writeText(s); }catch(e){}
|
|
651
|
+
try{ var ta=document.createElement('textarea'); ta.value=s; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); }catch(e){}
|
|
652
|
+
return true; };
|
|
653
|
+
host.addEventListener('mouseup', function(){ copySel(); });
|
|
654
|
+
host.addEventListener('touchend', function(){ copySel(); });
|
|
655
|
+
term.attachCustomKeyEventHandler(function(ev){
|
|
656
|
+
if(ev.type==='keydown' && (ev.metaKey||ev.ctrlKey) && (ev.key==='c'||ev.key==='C')){
|
|
657
|
+
if(term.hasSelection()){ copySel(); return false; }
|
|
658
|
+
}
|
|
659
|
+
return true;
|
|
660
|
+
});
|
|
612
661
|
// term.open 은 host 가 DOM 에 붙은 뒤(attachHosts) 최초 1회 — detached 에서 open 하면 렌더러가 안 뜬다.
|
|
613
662
|
var t={ runId:runId, name:tabName(runId), term:term, fit:fit, ws:null, retry:0, closing:false, host:host, ro:null, opened:false, connected:false };
|
|
614
663
|
term.onData(function(d){ if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i',d:d})); });
|
|
615
664
|
tabs[runId]=t; tabOrder.push(runId);
|
|
616
665
|
return t; // open·connect 는 attachHosts 에서(Phase 2 순서: open → connect)
|
|
617
666
|
}
|
|
667
|
+
|
|
668
|
+
// ── 파일 뷰어 탭(비터미널) — md/html/pdf/이미지/텍스트 보기 + .env 등 편집 ──
|
|
669
|
+
function ensureViewer(path, name){
|
|
670
|
+
var id='v'+(viewerSeq++);
|
|
671
|
+
var host=document.createElement('div'); host.className='view-host';
|
|
672
|
+
var t={ runId:id, name:name||(path.split('/').pop()||path), kind:'viewer', term:null, ws:null, host:host, path:path, closing:false, opened:false };
|
|
673
|
+
tabs[id]=t; tabOrder.push(id);
|
|
674
|
+
renderViewer(t);
|
|
675
|
+
return t;
|
|
676
|
+
}
|
|
677
|
+
function openViewer(path, name){ var t=ensureViewer(path, name); openTab(t.runId); }
|
|
678
|
+
function fmtSize(n){ return n<1024?(n+' B'):n<1048576?((n/1024).toFixed(1)+' KB'):((n/1048576).toFixed(1)+' MB'); }
|
|
679
|
+
var MD_FRAME_CSS = 'body{margin:0;padding:18px 22px;background:#0b0d12;color:#dee4ec;font:14px/1.7 -apple-system,BlinkMacSystemFont,\\'Apple SD Gothic Neo\\',\\'Noto Sans KR\\',sans-serif;max-width:800px}'
|
|
680
|
+
+ 'a{color:#4ec9b0}h1,h2,h3{color:#fff;line-height:1.3}h1{border-bottom:1px solid #2c3444;padding-bottom:.3em}h2{border-bottom:1px solid #232a36;padding-bottom:.25em}'
|
|
681
|
+
+ 'code{background:#1c212c;padding:.15em .4em;border-radius:4px;font-family:ui-monospace,Menlo,monospace;font-size:.9em}'
|
|
682
|
+
+ 'pre{background:#12161d;padding:12px 14px;border-radius:8px;overflow:auto}pre code{background:none;padding:0}'
|
|
683
|
+
+ 'blockquote{margin:0;padding:.2em 1em;border-left:3px solid #4ec9b0;color:#9aa4b2}'
|
|
684
|
+
+ 'table{border-collapse:collapse}th,td{border:1px solid #2c3444;padding:6px 10px}img{max-width:100%}hr{border:0;border-top:1px solid #2c3444}';
|
|
685
|
+
function renderViewer(t){
|
|
686
|
+
var host=t.host;
|
|
687
|
+
host.innerHTML='<div class="view-bar"><span class="vp"></span></div><div class="view-body"><div class="view-msg">불러오는 중…</div></div>';
|
|
688
|
+
var bar=host.querySelector('.view-bar'), body=host.querySelector('.view-body');
|
|
689
|
+
bar.querySelector('.vp').textContent=t.path;
|
|
690
|
+
var rawUrl='/api/fs/raw?path='+encodeURIComponent(t.path);
|
|
691
|
+
fetch('/api/fs/read?path='+encodeURIComponent(t.path)).then(function(r){ return r.json(); }).then(function(d){
|
|
692
|
+
if(d.error){ body.innerHTML='<div class="view-msg">열 수 없습니다: '+esc(d.error)+'</div>'; return; }
|
|
693
|
+
// 공통 액션: 원본 열기(새 탭/시스템)
|
|
694
|
+
var actions='<button data-act="raw" title="원본을 새 탭/시스템 뷰어로">↗ 원본</button>';
|
|
695
|
+
if(d.kind==='md' || d.kind==='html' || d.kind==='pdf' || d.kind==='image'){
|
|
696
|
+
bar.innerHTML='<span class="vp"></span>'+actions; bar.querySelector('.vp').textContent=t.path;
|
|
697
|
+
}
|
|
698
|
+
if(d.kind==='md'){
|
|
699
|
+
var html='<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>'+MD_FRAME_CSS+'</style></head><body>'
|
|
700
|
+
+ ((window.marked&&window.marked.parse)?window.marked.parse(d.text||''):esc(d.text||'')) + '</body></html>';
|
|
701
|
+
var f=document.createElement('iframe'); f.setAttribute('sandbox','allow-popups allow-popups-to-escape-sandbox'); f.srcdoc=html;
|
|
702
|
+
body.innerHTML=''; body.appendChild(f);
|
|
703
|
+
} else if(d.kind==='html'){
|
|
704
|
+
// 저장된/임의 HTML 은 신뢰 불가 → same-origin 금지 sandbox(불투명 출처라 쿠키·API 접근 차단)
|
|
705
|
+
var fh=document.createElement('iframe'); fh.setAttribute('sandbox','allow-scripts allow-popups allow-popups-to-escape-sandbox'); fh.src=rawUrl;
|
|
706
|
+
body.innerHTML=''; body.appendChild(fh);
|
|
707
|
+
} else if(d.kind==='pdf'){
|
|
708
|
+
var fp=document.createElement('iframe'); fp.src=rawUrl; // 브라우저/Electron 내장 PDF 뷰어(수동적 — sandbox 불필요)
|
|
709
|
+
body.innerHTML=''; body.appendChild(fp);
|
|
710
|
+
} else if(d.kind==='image'){
|
|
711
|
+
body.innerHTML=''; var im=document.createElement('img'); im.src=rawUrl; im.alt=t.name; body.appendChild(im);
|
|
712
|
+
} else if(d.kind==='binary'){
|
|
713
|
+
body.innerHTML='<div class="view-msg">미리보기 불가 · '+fmtSize(d.size||0)+(d.note?(' · '+esc(d.note)):'')+'<br><br><a href="'+rawUrl+'" target="_blank">원본 열기 / 내려받기</a></div>';
|
|
714
|
+
} else { // text
|
|
715
|
+
if(d.editable){ actions='<button data-act="edit" class="prim">편집</button>'+actions; }
|
|
716
|
+
bar.innerHTML='<span class="vp"></span>'+actions; bar.querySelector('.vp').textContent=t.path;
|
|
717
|
+
var pre=document.createElement('pre'); pre.className='vtext'; pre.textContent=d.text||''; body.innerHTML=''; body.appendChild(pre);
|
|
718
|
+
t._text=d.text||''; t._editable=!!d.editable;
|
|
719
|
+
}
|
|
720
|
+
}).catch(function(){ body.innerHTML='<div class="view-msg">불러오기 실패</div>'; });
|
|
721
|
+
|
|
722
|
+
// 바 액션(위임)
|
|
723
|
+
bar.addEventListener('click', function(e){
|
|
724
|
+
var b=e.target.closest('button[data-act]'); if(!b) return;
|
|
725
|
+
var act=b.getAttribute('data-act');
|
|
726
|
+
if(act==='raw'){ window.open(rawUrl,'_blank','noopener'); return; }
|
|
727
|
+
if(act==='edit'){ startEdit(t); return; }
|
|
728
|
+
if(act==='save'){ saveEdit(t); return; }
|
|
729
|
+
if(act==='cancel'){ renderViewer(t); return; }
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
function startEdit(t){
|
|
733
|
+
var bar=t.host.querySelector('.view-bar'), body=t.host.querySelector('.view-body');
|
|
734
|
+
bar.innerHTML='<span class="vp"></span><button data-act="save" class="prim">저장</button><button data-act="cancel">취소</button>';
|
|
735
|
+
bar.querySelector('.vp').textContent=t.path;
|
|
736
|
+
var ta=document.createElement('textarea'); ta.className='vedit'; ta.value=t._text||''; ta.spellcheck=false;
|
|
737
|
+
body.innerHTML=''; body.appendChild(ta); ta.focus();
|
|
738
|
+
ta.addEventListener('keydown', function(e){ if((e.metaKey||e.ctrlKey)&&e.key==='s'){ e.preventDefault(); saveEdit(t); } });
|
|
739
|
+
t._ta=ta;
|
|
740
|
+
}
|
|
741
|
+
function saveEdit(t){
|
|
742
|
+
var ta=t._ta; if(!ta) return; var content=ta.value;
|
|
743
|
+
fetch('/api/fs/write',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:t.path,content:content})})
|
|
744
|
+
.then(function(r){ return r.json(); }).then(function(d){
|
|
745
|
+
if(d.error){ toast('저장 실패: '+d.error); return; }
|
|
746
|
+
t._text=content; toast('저장됨 · '+t.name+' ('+fmtSize(d.size||0)+')'); renderViewer(t);
|
|
747
|
+
}).catch(function(){ toast('저장 실패'); });
|
|
748
|
+
}
|
|
749
|
+
|
|
618
750
|
function connectTab(t){
|
|
619
751
|
if (t.closing || !t.term) return;
|
|
620
752
|
try{ t.fit.fit(); }catch(e){}
|
|
@@ -638,14 +770,22 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
638
770
|
}
|
|
639
771
|
|
|
640
772
|
function fitTab(t){ if(!t||!t.fit||!t.term) return; try{ t.fit.fit(); if(t.ws&&t.ws.readyState===1) t.ws.send(JSON.stringify({t:'r',cols:t.term.cols,rows:t.term.rows})); }catch(e){} }
|
|
641
|
-
function fitAllVisible(){ eachLeaf(layout,function(l){ if(l.tab!=null && tabs[l.tab] && tabs[l.tab].host.isConnected) fitTab(tabs[l.tab]); }); }
|
|
773
|
+
function fitAllVisible(){ eachLeaf(layout,function(l){ if(l.tab!=null && tabs[l.tab] && tabs[l.tab].kind!=='viewer' && tabs[l.tab].host.isConnected) fitTab(tabs[l.tab]); }); }
|
|
642
774
|
|
|
643
775
|
// ── 탭 바 렌더(터미널 없음 — 언제든 안전) ──
|
|
644
776
|
function renderTabs(){
|
|
645
777
|
var shown={}; eachLeaf(layout,function(l){ if(l.tab!=null) shown[l.tab]=true; });
|
|
646
778
|
var html='';
|
|
647
779
|
tabOrder.forEach(function(runId){
|
|
648
|
-
var t=tabs[runId]; if(!t) return;
|
|
780
|
+
var t=tabs[runId]; if(!t) return;
|
|
781
|
+
if(t.kind==='viewer'){
|
|
782
|
+
html += '<div class="tab'+(shown[runId]?' shown':'')+'" draggable="true" data-tab="'+runId+'" title="파일 뷰어 · 드래그=페인에 배치">'
|
|
783
|
+
+ '<span class="st vdoc">▤</span>'
|
|
784
|
+
+ '<span class="nm">'+esc(t.name)+'</span>'
|
|
785
|
+
+ '<button class="x" title="뷰어 닫기">×</button></div>';
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
var r=runById[runId];
|
|
649
789
|
html += '<div class="tab'+(shown[runId]?' shown':'')+'" draggable="true" data-tab="'+runId+'" title="더블클릭=이름변경 · 드래그=페인에 배치">'
|
|
650
790
|
+ '<span class="st '+esc(r?r.status:'')+'"></span>'
|
|
651
791
|
+ '<span class="nm">'+esc(t.name)+'</span>'
|
|
@@ -659,11 +799,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
659
799
|
var t=node.tab!=null?tabs[node.tab]:null; var r=node.tab!=null?runById[node.tab]:null;
|
|
660
800
|
var head=document.createElement('div'); head.className='leaf-h'; head.dataset.leafhead=node.id;
|
|
661
801
|
head.innerHTML = t
|
|
662
|
-
?
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
802
|
+
? (t.kind==='viewer'
|
|
803
|
+
? '<span class="st vdoc">▤</span><span class="nm">'+esc(t.name)+'</span>'
|
|
804
|
+
+ '<button class="x" title="이 페인 닫기(뷰어 유지)">×</button>'
|
|
805
|
+
: '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
|
|
806
|
+
+ '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
|
|
807
|
+
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
808
|
+
+ '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">⊟</button>'
|
|
809
|
+
+ '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>')
|
|
667
810
|
: '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
|
|
668
811
|
var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
|
|
669
812
|
if (!t){ var em=document.createElement('div'); em.className='leaf-empty'; em.textContent='탭을 여기로 드래그하거나 탭을 클릭하세요'; body.appendChild(em); }
|
|
@@ -678,6 +821,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
678
821
|
}
|
|
679
822
|
// host 를 DOM 에 붙이고, 최초 1회 open → connect(Phase 2 순서). fit 은 render 의 rAF 에서.
|
|
680
823
|
function attachHosts(){ eachLeaf(layout,function(l){ if(l.tab==null||!tabs[l.tab]) return; var t=tabs[l.tab]; var body=$('panes').querySelector('[data-leafbody="'+l.id+'"]'); if(!body) return; body.appendChild(t.host);
|
|
824
|
+
if(t.kind==='viewer') return; // 뷰어는 DOM 만 붙이면 끝(터미널 open/connect 없음)
|
|
681
825
|
if(!t.opened){ try{ t.term.open(t.host); t.opened=true; }catch(e){} }
|
|
682
826
|
if(!t.connected){ t.connected=true; connectTab(t); }
|
|
683
827
|
}); }
|
|
@@ -701,14 +845,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
701
845
|
function setLeafFocus(id){
|
|
702
846
|
focusLeaf=id;
|
|
703
847
|
Array.prototype.forEach.call($('panes').querySelectorAll('.leaf'),function(el){ el.classList.toggle('focus', el.dataset.leaf===id); });
|
|
704
|
-
var rid=focusedRunId(); if(rid!=null && tabs[rid]) try{ tabs[rid].term.focus(); }catch(e){}
|
|
848
|
+
var rid=focusedRunId(); if(rid!=null && tabs[rid] && tabs[rid].term) try{ tabs[rid].term.focus(); }catch(e){}
|
|
705
849
|
updateControls();
|
|
706
850
|
if (typeof reqMode!=='undefined' && reqMode!=='new') setMode(reqMode);
|
|
707
851
|
}
|
|
708
852
|
|
|
709
853
|
// 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
|
|
710
854
|
function openTab(runId){
|
|
711
|
-
ensureTab(runId);
|
|
855
|
+
if(!tabs[runId]) ensureTab(runId); // 뷰어 탭은 ensureViewer 로 이미 생성됨 → 터미널로 오생성 방지
|
|
712
856
|
var l=leafOfTab(runId);
|
|
713
857
|
if (l){ setLeafFocus(l.id); return; }
|
|
714
858
|
var f=findLeaf(focusLeaf) || firstLeaf();
|
|
@@ -743,7 +887,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
743
887
|
var t=tabs[runId]; if(!t) return; t.closing=true;
|
|
744
888
|
try{ if(t.ws) t.ws.close(); }catch(e){}
|
|
745
889
|
try{ if(t.ro) t.ro.disconnect(); }catch(e){}
|
|
746
|
-
try{ t.term.dispose(); }catch(e){}
|
|
890
|
+
try{ if(t.term) t.term.dispose(); }catch(e){}
|
|
747
891
|
eachLeaf(layout,function(l){ if(l.tab===runId) l.tab=null; });
|
|
748
892
|
delete tabs[runId]; tabOrder=tabOrder.filter(function(x){return x!==runId;});
|
|
749
893
|
render(); renderTree();
|
|
@@ -765,8 +909,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
765
909
|
|
|
766
910
|
// fleet 갱신 시 라벨·상태만 갱신(페인 DOM 재구성 X — 터미널 유지). 사라진 run 탭 정리.
|
|
767
911
|
function syncPanes(){
|
|
768
|
-
tabOrder.slice().forEach(function(runId){ if(!runById[runId]) closeTab(runId); });
|
|
769
|
-
tabOrder.forEach(function(runId){ var t=tabs[runId]; if(t) t.name=tabName(runId); });
|
|
912
|
+
tabOrder.slice().forEach(function(runId){ if(!isViewer(runId) && !runById[runId]) closeTab(runId); }); // 뷰어는 run 이 없어도 유지
|
|
913
|
+
tabOrder.forEach(function(runId){ var t=tabs[runId]; if(t && t.kind!=='viewer') t.name=tabName(runId); });
|
|
770
914
|
renderTabs();
|
|
771
915
|
eachLeaf(layout,function(l){
|
|
772
916
|
if(l.tab==null) return; var r=runById[l.tab]; if(!r) return;
|
|
@@ -805,12 +949,12 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
805
949
|
|
|
806
950
|
// ── 탭 바 이벤트 ──
|
|
807
951
|
$('tabs').addEventListener('click', function(e){
|
|
808
|
-
var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; var runId
|
|
952
|
+
var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; var runId=tabIdOf(tabEl);
|
|
809
953
|
if (e.target.closest('.x')){ closeTab(runId); return; }
|
|
810
954
|
openTab(runId);
|
|
811
955
|
});
|
|
812
|
-
$('tabs').addEventListener('dblclick', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) startRename(
|
|
813
|
-
$('tabs').addEventListener('dragstart', function(e){ var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; dragRunId
|
|
956
|
+
$('tabs').addEventListener('dblclick', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) startRename(tabIdOf(tabEl), tabEl); });
|
|
957
|
+
$('tabs').addEventListener('dragstart', function(e){ var tabEl=e.target.closest('[data-tab]'); if(!tabEl) return; dragRunId=tabIdOf(tabEl); tabEl.classList.add('drag'); try{ e.dataTransfer.effectAllowed='move'; e.dataTransfer.setData('text/plain', String(dragRunId)); }catch(_){} });
|
|
814
958
|
$('tabs').addEventListener('dragend', function(e){ var tabEl=e.target.closest('[data-tab]'); if(tabEl) tabEl.classList.remove('drag'); dragRunId=null; });
|
|
815
959
|
|
|
816
960
|
// ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
|
|
@@ -870,7 +1014,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
870
1014
|
|
|
871
1015
|
// ── 뷰어 — 위 내용을 (대화) Claude Code 로그 / (터미널) 스크롤백 으로 읽기(읽기 전용) ──
|
|
872
1016
|
var histMode='chat';
|
|
873
|
-
function openHistory(){ var rid=focusedRunId(); if(rid==null){ toast('포커스한 세션이 없습니다'); return; } $('histModal').classList.add('on'); loadHist(); }
|
|
1017
|
+
function openHistory(){ var rid=focusedRunId(); if(rid==null||isViewer(rid)){ toast('포커스한 세션이 없습니다'); return; } $('histModal').classList.add('on'); loadHist(); }
|
|
874
1018
|
function closeHistory(){ $('histModal').classList.remove('on'); }
|
|
875
1019
|
function setHistMode(m){
|
|
876
1020
|
histMode=m; $('hmChat').classList.toggle('on',m==='chat'); $('hmRaw').classList.toggle('on',m==='raw');
|
|
@@ -948,6 +1092,35 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
948
1092
|
});
|
|
949
1093
|
$('pickName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('pickGo').click(); } });
|
|
950
1094
|
$('sessionBtn').addEventListener('click', openSession);
|
|
1095
|
+
|
|
1096
|
+
// ── 파일 피커(뷰어로 열기) — 폴더=이동, 파일=뷰어 ──
|
|
1097
|
+
var fpDirCur = '';
|
|
1098
|
+
function openFilePicker(){ $('fpickModal').classList.add('on'); fpTo(fpDirCur||''); }
|
|
1099
|
+
function closeFilePicker(){ $('fpickModal').classList.remove('on'); }
|
|
1100
|
+
function fpIcon(kind){ return kind==='md'?'M':kind==='html'?'H':kind==='pdf'?'P':kind==='image'?'I':kind==='binary'?'·':'T'; }
|
|
1101
|
+
async function fpTo(p){
|
|
1102
|
+
try{
|
|
1103
|
+
var d = await (await fetch('/api/fs/list'+(p?('?path='+encodeURIComponent(p)):''))).json();
|
|
1104
|
+
fpDirCur = d.path; $('fpPath').textContent = d.path;
|
|
1105
|
+
var html='';
|
|
1106
|
+
if (d.parent && d.parent!==d.path) html += '<div class="pick-row up" data-dir="'+esc(d.parent)+'"><span class="ic">↑</span><span>..</span></div>';
|
|
1107
|
+
(d.entries||[]).forEach(function(e){
|
|
1108
|
+
var full = d.path==='/' ? '/'+e.name : d.path+'/'+e.name;
|
|
1109
|
+
if (e.dir) html += '<div class="pick-row" data-dir="'+esc(full)+'"><span class="ic">▸</span><span>'+esc(e.name)+'</span></div>';
|
|
1110
|
+
else html += '<div class="pick-row" data-file="'+esc(full)+'"><span class="ic">'+fpIcon(e.kind)+'</span><span>'+esc(e.name)+'</span></div>';
|
|
1111
|
+
});
|
|
1112
|
+
if (!(d.entries||[]).length) html += '<div class="pick-row" style="cursor:default;color:var(--faint)">'+(d.error?'읽을 수 없습니다':'항목 없음')+'</div>';
|
|
1113
|
+
$('fpList').innerHTML = html;
|
|
1114
|
+
}catch(e){ $('fpList').innerHTML = '<div class="pick-row" style="color:var(--failed)">읽을 수 없습니다</div>'; }
|
|
1115
|
+
}
|
|
1116
|
+
$('fpList').addEventListener('click', function(e){
|
|
1117
|
+
var dir=e.target.closest('[data-dir]'); if(dir){ fpTo(dir.dataset.dir); return; }
|
|
1118
|
+
var file=e.target.closest('[data-file]'); if(file){ closeFilePicker(); openViewer(file.dataset.file); if(isMobile()) setDrawer(false); }
|
|
1119
|
+
});
|
|
1120
|
+
$('fpClose').addEventListener('click', closeFilePicker);
|
|
1121
|
+
$('fpHome').addEventListener('click', function(){ fpTo(''); });
|
|
1122
|
+
$('fpickModal').addEventListener('click', function(e){ if(e.target===this) closeFilePicker(); });
|
|
1123
|
+
$('fileBtn').addEventListener('click', openFilePicker);
|
|
951
1124
|
$('sessionCta').addEventListener('click', openSession);
|
|
952
1125
|
|
|
953
1126
|
// ── (A) 시크릿 볼트 — 세션 env 주입 ──
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// File viewer/edit backing (owner-only tool). Scoped to the daemon user's home
|
|
2
|
+
// directory — reading file *contents* is higher-risk than /api/browse's dir listing,
|
|
3
|
+
// so we jail here. All real work (repos, worktrees, sessions, ~/services) lives under ~.
|
|
4
|
+
import { readdir, stat, readFile, writeFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { resolve as presolve, dirname as pdirname, join as pjoin, basename as pbasename, extname as pextname } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const HOME = presolve(homedir());
|
|
9
|
+
const MAX_TEXT = 2 * 1024 * 1024; // read as text up to 2MB
|
|
10
|
+
const MAX_WRITE = 512 * 1024; // edit-save cap (.env etc. are tiny)
|
|
11
|
+
|
|
12
|
+
export type FileKind = 'md' | 'html' | 'pdf' | 'image' | 'text' | 'binary';
|
|
13
|
+
|
|
14
|
+
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']);
|
|
15
|
+
const MD_EXT = new Set(['.md', '.markdown', '.mdx']);
|
|
16
|
+
const HTML_EXT = new Set(['.html', '.htm']);
|
|
17
|
+
// recognized text families (default is a NUL-byte sniff, this just short-circuits)
|
|
18
|
+
const TEXT_EXT = new Set([
|
|
19
|
+
'.txt', '.env', '.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini', '.conf', '.cfg',
|
|
20
|
+
'.log', '.csv', '.tsv', '.xml', '.sql', '.sh', '.bash', '.zsh', '.fish',
|
|
21
|
+
'.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.css', '.scss', '.less',
|
|
22
|
+
'.py', '.rb', '.php', '.go', '.rs', '.java', '.c', '.h', '.cpp', '.hpp', '.cc',
|
|
23
|
+
'.swift', '.kt', '.lua', '.pl', '.r', '.dart', '.vue', '.svelte', '.gradle',
|
|
24
|
+
'.gitignore', '.dockerignore', '.editorconfig', '.properties', '.svg',
|
|
25
|
+
]);
|
|
26
|
+
const TEXT_BASENAME = new Set(['dockerfile', 'makefile', 'readme', 'license', 'procfile', '.env', '.gitignore', '.npmrc', '.prettierrc', '.eslintrc']);
|
|
27
|
+
|
|
28
|
+
const MIME: Record<string, string> = {
|
|
29
|
+
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
30
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp',
|
|
31
|
+
'.ico': 'image/x-icon', '.avif': 'image/avif', '.html': 'text/html', '.htm': 'text/html',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function ext(p: string) { return pextname(p).toLowerCase(); }
|
|
35
|
+
|
|
36
|
+
export function classify(p: string): FileKind {
|
|
37
|
+
const e = ext(p);
|
|
38
|
+
const base = pbasename(p).toLowerCase();
|
|
39
|
+
if (MD_EXT.has(e)) return 'md';
|
|
40
|
+
if (HTML_EXT.has(e)) return 'html';
|
|
41
|
+
if (e === '.pdf') return 'pdf';
|
|
42
|
+
if (e === '.svg') return 'image'; // render, but also text-editable via read()
|
|
43
|
+
if (IMAGE_EXT.has(e)) return 'image';
|
|
44
|
+
if (TEXT_EXT.has(e) || TEXT_BASENAME.has(base) || base.startsWith('.env')) return 'text';
|
|
45
|
+
return 'binary'; // decided for real in read() via NUL sniff
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function mimeFor(p: string): string {
|
|
49
|
+
return MIME[ext(p)] || 'application/octet-stream';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Reject anything that escapes HOME (symlink-safe via realpath on the closest existing ancestor).
|
|
53
|
+
async function jail(input?: string): Promise<string> {
|
|
54
|
+
const p = presolve(input && input.startsWith('/') ? input : HOME);
|
|
55
|
+
// realpath the deepest existing ancestor so a missing leaf (new file) still validates its dir
|
|
56
|
+
let probe = p;
|
|
57
|
+
for (;;) {
|
|
58
|
+
try { const rp = await realpath(probe); const rest = p.slice(probe.length); const full = presolve(rp + rest);
|
|
59
|
+
if (full !== HOME && !full.startsWith(HOME + '/')) throw new Error('outside home');
|
|
60
|
+
return full;
|
|
61
|
+
} catch (e: any) {
|
|
62
|
+
if (e && e.message === 'outside home') throw e;
|
|
63
|
+
const parent = pdirname(probe);
|
|
64
|
+
if (parent === probe) { if (p !== HOME && !p.startsWith(HOME + '/')) throw new Error('outside home'); return p; }
|
|
65
|
+
probe = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function listDir(input?: string) {
|
|
71
|
+
const path = await jail(input);
|
|
72
|
+
const entries: Array<{ name: string; dir: boolean; size: number; kind?: FileKind }> = [];
|
|
73
|
+
let error: string | undefined;
|
|
74
|
+
try {
|
|
75
|
+
const items = await readdir(path, { withFileTypes: true });
|
|
76
|
+
for (const it of items) {
|
|
77
|
+
// hide most dotfiles, but keep .env* visible (the one text file they edit)
|
|
78
|
+
if (it.name.startsWith('.') && !it.name.startsWith('.env')) continue;
|
|
79
|
+
const dir = it.isDirectory();
|
|
80
|
+
let size = 0;
|
|
81
|
+
if (!dir) { try { size = (await stat(pjoin(path, it.name))).size; } catch { size = 0; } }
|
|
82
|
+
entries.push({ name: it.name, dir, size, kind: dir ? undefined : classify(it.name) });
|
|
83
|
+
if (entries.length >= 500) break;
|
|
84
|
+
}
|
|
85
|
+
entries.sort((a, b) => (b.dir ? 1 : 0) - (a.dir ? 1 : 0) || a.name.localeCompare(b.name));
|
|
86
|
+
} catch { error = 'cannot read directory'; }
|
|
87
|
+
return { path, parent: pdirname(path), home: HOME, entries, error };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function readForView(input?: string) {
|
|
91
|
+
const path = await jail(input);
|
|
92
|
+
const st = await stat(path);
|
|
93
|
+
if (st.isDirectory()) throw new Error('is a directory');
|
|
94
|
+
const name = pbasename(path);
|
|
95
|
+
let kind = classify(path);
|
|
96
|
+
const size = st.size;
|
|
97
|
+
|
|
98
|
+
// For pdf/image/html the client fetches /api/fs/raw; no body needed here.
|
|
99
|
+
if (kind === 'pdf' || kind === 'image' || kind === 'html') {
|
|
100
|
+
return { path, name, kind, size, editable: false };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// text / md / unknown → try to read as text (NUL sniff decides 'binary')
|
|
104
|
+
if (size > MAX_TEXT) return { path, name, kind: 'binary' as FileKind, size, editable: false, note: 'too large to preview' };
|
|
105
|
+
const buf = await readFile(path);
|
|
106
|
+
if (buf.includes(0)) return { path, name, kind: 'binary' as FileKind, size, editable: false };
|
|
107
|
+
const text = buf.toString('utf8');
|
|
108
|
+
if (kind === 'binary') kind = 'text'; // unknown ext but no NUL → treat as text
|
|
109
|
+
const editable = (kind === 'text' || kind === 'md') && size <= MAX_WRITE;
|
|
110
|
+
return { path, name, kind, size, editable, text };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Raw bytes with correct content-type — this is what makes PDFs/images/html render.
|
|
114
|
+
export async function readRaw(input?: string) {
|
|
115
|
+
const path = await jail(input);
|
|
116
|
+
const st = await stat(path);
|
|
117
|
+
if (st.isDirectory()) throw new Error('is a directory');
|
|
118
|
+
return { path, name: pbasename(path), mime: mimeFor(path), buf: await readFile(path) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function writeText(input: string, content: string) {
|
|
122
|
+
const path = await jail(input);
|
|
123
|
+
const st = await stat(path).catch(() => null);
|
|
124
|
+
if (st && st.isDirectory()) throw new Error('is a directory');
|
|
125
|
+
if (st && st.size > MAX_WRITE) throw new Error('file too large to edit here');
|
|
126
|
+
if (Buffer.byteLength(content, 'utf8') > MAX_WRITE) throw new Error('content exceeds edit cap (512KB)');
|
|
127
|
+
const kind = classify(path);
|
|
128
|
+
if (kind === 'pdf' || kind === 'image' || kind === 'binary') throw new Error('not an editable text file');
|
|
129
|
+
await writeFile(path, content, 'utf8');
|
|
130
|
+
return { path, name: pbasename(path), size: Buffer.byteLength(content, 'utf8') };
|
|
131
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { getProvider, listProviders } from './providers';
|
|
|
26
26
|
import { remoteState, setServe, setFunnel } from './remote';
|
|
27
27
|
import { BOARD_HTML } from './board';
|
|
28
28
|
import { COCKPIT_HTML } from './cockpit';
|
|
29
|
+
import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText } from './files';
|
|
29
30
|
|
|
30
31
|
const require_ = createRequire(import.meta.url);
|
|
31
32
|
|
|
@@ -36,6 +37,7 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
|
36
37
|
'addon-fit.js': { pkg: '@xterm/addon-fit/package.json', rel: 'lib/addon-fit.js', type: 'text/javascript' },
|
|
37
38
|
'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
|
|
38
39
|
'addon-web-links.js': { pkg: '@xterm/addon-web-links/package.json', rel: 'lib/addon-web-links.js', type: 'text/javascript' },
|
|
40
|
+
'marked.js': { pkg: 'marked/package.json', rel: 'marked.min.js', type: 'text/javascript' },
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
// ─── 읽기 전용 공유 페이지 (서버 렌더 스냅샷 — 스크립트 0, 액션 0) ───────────
|
|
@@ -743,6 +745,38 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
743
745
|
return { path: p, parent: pdirname(p), home: homedir(), isRepo: existsSync(pjoin(p, '.git')), dirs, error };
|
|
744
746
|
});
|
|
745
747
|
|
|
748
|
+
// ─── File viewer (md/html/pdf/image/text 보기 + .env 등 작은 텍스트 편집) ──────
|
|
749
|
+
// 홈 디렉터리로 감옥(files.ts). owner 전용 도구지만 파일 *내용* 읽기는 browse 보다 위험 → 잼.
|
|
750
|
+
app.get('/api/fs/list', async (req) => {
|
|
751
|
+
const q = (req.query ?? {}) as { path?: string };
|
|
752
|
+
try { return await fsListDir(q.path); }
|
|
753
|
+
catch { return { path: q.path ?? '', parent: '', home: homedir(), entries: [], error: 'outside home or unreadable' }; }
|
|
754
|
+
});
|
|
755
|
+
app.get('/api/fs/read', async (req, reply) => {
|
|
756
|
+
const q = (req.query ?? {}) as { path?: string };
|
|
757
|
+
if (!q.path) return reply.code(400).send({ error: 'path required' });
|
|
758
|
+
try { return await fsReadForView(q.path); }
|
|
759
|
+
catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
|
|
760
|
+
});
|
|
761
|
+
app.get('/api/fs/raw', async (req, reply) => {
|
|
762
|
+
const q = (req.query ?? {}) as { path?: string };
|
|
763
|
+
if (!q.path) return reply.code(400).send({ error: 'path required' });
|
|
764
|
+
try {
|
|
765
|
+
const { name, mime, buf } = await fsReadRaw(q.path);
|
|
766
|
+
// inline so the browser/Electron renders (PDF plugin, <img>, sandboxed <iframe>) instead of downloading
|
|
767
|
+
return reply.type(mime)
|
|
768
|
+
.header('content-disposition', 'inline; filename="' + name.replace(/[^\w.\- ]/g, '_') + '"')
|
|
769
|
+
.header('x-content-type-options', 'nosniff')
|
|
770
|
+
.send(buf);
|
|
771
|
+
} catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
|
|
772
|
+
});
|
|
773
|
+
app.post('/api/fs/write', async (req, reply) => {
|
|
774
|
+
const b = (req.body ?? {}) as { path?: string; content?: string };
|
|
775
|
+
if (!b.path || typeof b.content !== 'string') return reply.code(400).send({ error: 'path and content required' });
|
|
776
|
+
try { return await fsWriteText(b.path, b.content); }
|
|
777
|
+
catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
|
|
778
|
+
});
|
|
779
|
+
|
|
746
780
|
// ─── Design Mode ───────────────────────────────────────────────
|
|
747
781
|
// 캡처 키: 인증 off 면 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
|
|
748
782
|
const captureKeyOk = (req: { query?: unknown }): boolean => {
|