coxpit 5.26.2 → 5.27.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 +41 -4
- package/src/files.ts +19 -0
- package/src/server.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.27.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",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@fastify/websocket": "^11",
|
|
46
46
|
"@libsql/client": "^0.14",
|
|
47
|
+
"@xterm/addon-clipboard": "^0.1.0",
|
|
47
48
|
"@xterm/addon-fit": "^0.10.0",
|
|
48
49
|
"@xterm/addon-search": "^0.15.0",
|
|
49
50
|
"@xterm/addon-unicode11": "^0.8.0",
|
package/src/cockpit.ts
CHANGED
|
@@ -552,6 +552,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
552
552
|
<script src="/vendor/addon-unicode11.js"></script>
|
|
553
553
|
<script src="/vendor/addon-web-links.js"></script>
|
|
554
554
|
<script src="/vendor/addon-search.js"></script>
|
|
555
|
+
<script src="/vendor/addon-clipboard.js"></script>
|
|
555
556
|
<script src="/vendor/marked.js"></script>
|
|
556
557
|
<script>
|
|
557
558
|
// 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
|
|
@@ -770,15 +771,27 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
770
771
|
}
|
|
771
772
|
cb(links.length?links:undefined);
|
|
772
773
|
}}); }catch(e){}
|
|
774
|
+
// OSC 52 클립보드 — 터미널 안 앱(claude 등)이 "복사"하면 실제 시스템 클립보드로. (앱은 됐다는데 안 붙던 원인)
|
|
775
|
+
try{ term.loadAddon(new window.ClipboardAddon.ClipboardAddon()); }catch(e){}
|
|
773
776
|
// 스크롤백 검색(⌘F) — SearchAddon. t.search 에 보관, 검색바가 findNext/Previous 호출.
|
|
774
777
|
var search=null; try{ search=new window.SearchAddon.SearchAddon(); term.loadAddon(search);
|
|
775
778
|
search.onDidChangeResults(function(e){ if($('termFind') && !$('termFind').hidden && focusedRunId()===runId){ var c=$('findCount'); if(c){ var n=(e&&e.resultCount)||0; c.textContent = n ? (((e.resultIndex>=0?e.resultIndex+1:0))+'/'+n) : '0'; } } });
|
|
776
779
|
}catch(e){}
|
|
777
780
|
// 복사 배선: xterm 은 user-select:none 이라 네이티브 선택이 없다 → term.getSelection() 을 직접 클립보드로.
|
|
778
781
|
// ① 드래그 놓으면 자동 복사(select-to-copy) ② Cmd/Ctrl+C 로도 복사(선택 없으면 통과 → SIGINT).
|
|
782
|
+
// 복사: clipboard API 우선(HTTPS·PWA), 실패하면 execCommand 폴백(iOS 포함). 항상 토스트로 확인.
|
|
783
|
+
var copyFallback=function(s){ var ok=false;
|
|
784
|
+
try{ var ta=document.createElement('textarea'); ta.value=s; ta.setAttribute('readonly',''); ta.contentEditable='true';
|
|
785
|
+
ta.style.position='fixed'; ta.style.top='-9999px'; ta.style.opacity='0'; document.body.appendChild(ta);
|
|
786
|
+
var r=document.createRange(); r.selectNodeContents(ta); var sel=window.getSelection(); sel.removeAllRanges(); sel.addRange(r);
|
|
787
|
+
try{ ta.setSelectionRange(0, s.length); }catch(e){}
|
|
788
|
+
ok=document.execCommand('copy'); document.body.removeChild(ta);
|
|
789
|
+
}catch(e){ ok=false; }
|
|
790
|
+
toast(ok?('복사됨 · '+s.length+'자'):'복사 실패 — 브라우저가 클립보드를 막았어요'); };
|
|
779
791
|
var copySel=function(){ var s=''; try{ s=term.getSelection(); }catch(e){} if(!s) return false;
|
|
780
|
-
|
|
781
|
-
|
|
792
|
+
if(navigator.clipboard && navigator.clipboard.writeText){
|
|
793
|
+
navigator.clipboard.writeText(s).then(function(){ toast('복사됨 · '+s.length+'자'); }).catch(function(){ copyFallback(s); });
|
|
794
|
+
} else { copyFallback(s); }
|
|
782
795
|
return true; };
|
|
783
796
|
host.addEventListener('mouseup', function(){ copySel(); });
|
|
784
797
|
host.addEventListener('touchend', function(){ copySel(); });
|
|
@@ -1169,14 +1182,38 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1169
1182
|
if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
|
|
1170
1183
|
setLeafFocus(leaf.dataset.leaf);
|
|
1171
1184
|
});
|
|
1172
|
-
|
|
1185
|
+
function dragHasFiles(e){ try{ return e.dataTransfer && Array.prototype.indexOf.call(e.dataTransfer.types||[],'Files')>=0; }catch(_){ return false; } }
|
|
1186
|
+
$('panes').addEventListener('dragover', function(e){ var body=e.target.closest('[data-leafbody]'); if(body && (dragRunId!=null || dragHasFiles(e))){ e.preventDefault(); if(dragHasFiles(e)) try{ e.dataTransfer.dropEffect='copy'; }catch(_){}; body.classList.add('drop'); } });
|
|
1173
1187
|
$('panes').addEventListener('dragleave', function(e){ var body=e.target.closest('[data-leafbody]'); if(body) body.classList.remove('drop'); });
|
|
1174
1188
|
$('panes').addEventListener('drop', function(e){
|
|
1175
|
-
var body=e.target.closest('[data-leafbody]'); if(!body
|
|
1189
|
+
var body=e.target.closest('[data-leafbody]'); if(!body) return;
|
|
1190
|
+
if(dragHasFiles(e)){ e.preventDefault(); body.classList.remove('drop'); var lf=findLeaf(body.dataset.leafbody);
|
|
1191
|
+
if(lf && lf.tab!=null && e.dataTransfer.files && e.dataTransfer.files.length) uploadToTab(lf.tab, e.dataTransfer.files); else toast('업로드할 터미널 페인이 아닙니다'); return; }
|
|
1192
|
+
if(dragRunId==null) return; e.preventDefault(); body.classList.remove('drop');
|
|
1176
1193
|
var l=findLeaf(body.dataset.leafbody); if(!l) return;
|
|
1177
1194
|
var rid=dragRunId; eachLeaf(layout,function(x){ if(x.tab===rid) x.tab=null; });
|
|
1178
1195
|
ensureTab(rid); l.tab=rid; focusLeaf=l.id; render(); renderTree();
|
|
1179
1196
|
});
|
|
1197
|
+
// 파일 첨부 — 로컬 파일을 그 페인 run 의 작업폴더(cwd)로 업로드하고 경로를 터미널에 삽입.
|
|
1198
|
+
function uploadToTab(tabId, files){
|
|
1199
|
+
var t=tabs[tabId]; if(!t || t.kind==='viewer' || !t.ws){ toast('터미널 페인에만 첨부할 수 있어요'); return; }
|
|
1200
|
+
var r=runById[tabId]; var cwd=r&&r.worktreePath; if(!cwd){ toast('이 페인의 작업 폴더를 몰라 첨부할 수 없어요'); return; }
|
|
1201
|
+
Array.prototype.forEach.call(files, function(file){
|
|
1202
|
+
if(file.size > 25*1024*1024){ toast('너무 큼(25MB 초과): '+file.name); return; }
|
|
1203
|
+
var reader=new FileReader();
|
|
1204
|
+
reader.onload=function(){
|
|
1205
|
+
var b64=String(reader.result||'').split(',')[1]||'';
|
|
1206
|
+
fetch('/api/fs/upload',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({path:cwd, name:file.name, dataB64:b64})})
|
|
1207
|
+
.then(function(x){return x.json();}).then(function(d){
|
|
1208
|
+
if(d.error){ toast('첨부 실패: '+d.error); return; }
|
|
1209
|
+
var p=d.path; var ins=/[ "'\\\\]/.test(p) ? ('"'+p.replace(/(["\\\\])/g,'\\\\$1')+'"') : p;
|
|
1210
|
+
if(t.ws && t.ws.readyState===1) t.ws.send(JSON.stringify({t:'i', d:ins+' '}));
|
|
1211
|
+
toast('첨부됨 · '+d.name+' → 경로 삽입');
|
|
1212
|
+
}).catch(function(){ toast('첨부 실패'); });
|
|
1213
|
+
};
|
|
1214
|
+
reader.readAsDataURL(file);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1180
1217
|
$('panes').addEventListener('mousedown', function(e){
|
|
1181
1218
|
var g=e.target.closest('[data-gutter]'); if(!g) return; e.preventDefault();
|
|
1182
1219
|
var node=findSplit(g.dataset.gutter); if(!node) return;
|
package/src/files.ts
CHANGED
|
@@ -186,6 +186,25 @@ export async function readRaw(input?: string) {
|
|
|
186
186
|
return { path, name: pbasename(path), mime: mimeFor(path), buf: await readFile(path) };
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
const MAX_UPLOAD = 25 * 1024 * 1024; // 25MB
|
|
190
|
+
// Save an uploaded file into a directory (within root). Returns the final path (dedup, never clobbers).
|
|
191
|
+
export async function uploadFile(dir: string, name: string, buf: Buffer) {
|
|
192
|
+
if (buf.length > MAX_UPLOAD) throw new Error('파일이 너무 큽니다 (25MB 초과)');
|
|
193
|
+
const base = (name || 'file').replace(/[\/\x00]/g, '_').replace(/^\.+/, '').slice(0, 200) || 'file';
|
|
194
|
+
const dot = base.lastIndexOf('.');
|
|
195
|
+
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
196
|
+
const ext = dot > 0 ? base.slice(dot) : '';
|
|
197
|
+
const cleanDir = (dir || '').replace(/\/+$/, '');
|
|
198
|
+
let path = await jail(cleanDir + '/' + base); // jail validates within root
|
|
199
|
+
for (let i = 1; i < 100; i++) {
|
|
200
|
+
const st = await stat(path).catch(() => null);
|
|
201
|
+
if (!st) break; // free name
|
|
202
|
+
path = await jail(cleanDir + '/' + stem + '-' + i + ext);
|
|
203
|
+
}
|
|
204
|
+
await writeFile(path, buf);
|
|
205
|
+
return { path, name: pbasename(path), size: buf.length };
|
|
206
|
+
}
|
|
207
|
+
|
|
189
208
|
export async function writeText(input: string, content: string) {
|
|
190
209
|
let path: string;
|
|
191
210
|
try { path = await jail(input); } catch (e) { throw friendly(e); }
|
package/src/server.ts
CHANGED
|
@@ -26,7 +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, findFiles as fsFindFiles } from './files';
|
|
29
|
+
import { listDir as fsListDir, readForView as fsReadForView, readRaw as fsReadRaw, writeText as fsWriteText, findFiles as fsFindFiles, uploadFile as fsUploadFile } from './files';
|
|
30
30
|
|
|
31
31
|
const require_ = createRequire(import.meta.url);
|
|
32
32
|
|
|
@@ -38,6 +38,7 @@ const VENDOR: Record<string, { pkg: string; rel: string; type: string }> = {
|
|
|
38
38
|
'addon-unicode11.js': { pkg: '@xterm/addon-unicode11/package.json', rel: 'lib/addon-unicode11.js', type: 'text/javascript' },
|
|
39
39
|
'addon-web-links.js': { pkg: '@xterm/addon-web-links/package.json', rel: 'lib/addon-web-links.js', type: 'text/javascript' },
|
|
40
40
|
'addon-search.js': { pkg: '@xterm/addon-search/package.json', rel: 'lib/addon-search.js', type: 'text/javascript' },
|
|
41
|
+
'addon-clipboard.js': { pkg: '@xterm/addon-clipboard/package.json', rel: 'lib/addon-clipboard.js', type: 'text/javascript' },
|
|
41
42
|
'marked.js': { pkg: 'marked/package.json', rel: 'marked.min.js', type: 'text/javascript' },
|
|
42
43
|
};
|
|
43
44
|
|
|
@@ -784,6 +785,13 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
784
785
|
.send(buf);
|
|
785
786
|
} catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
|
|
786
787
|
});
|
|
788
|
+
// 파일 첨부 — 로컬 파일을 세션 폴더로 업로드(base64). 경로 삽입은 클라가 함. 32MB 바디 허용.
|
|
789
|
+
app.post('/api/fs/upload', { bodyLimit: 32 * 1024 * 1024 }, async (req, reply) => {
|
|
790
|
+
const b = (req.body ?? {}) as { path?: string; name?: string; dataB64?: string };
|
|
791
|
+
if (!b.path || !b.name || typeof b.dataB64 !== 'string') return reply.code(400).send({ error: 'path, name, dataB64 required' });
|
|
792
|
+
try { const buf = Buffer.from(b.dataB64, 'base64'); return await fsUploadFile(b.path, b.name, buf); }
|
|
793
|
+
catch (e: any) { return reply.code(400).send({ error: String(e?.message || e) }); }
|
|
794
|
+
});
|
|
787
795
|
app.post('/api/fs/write', async (req, reply) => {
|
|
788
796
|
const b = (req.body ?? {}) as { path?: string; content?: string };
|
|
789
797
|
if (!b.path || typeof b.content !== 'string') return reply.code(400).send({ error: 'path and content required' });
|