coxpit 5.12.1 → 5.13.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 +1 -1
- package/src/cockpit.ts +73 -1
- package/src/db/index.ts +6 -0
- package/src/db/schema.ts +8 -0
- package/src/orchestrator.ts +22 -5
- package/src/server.ts +24 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.13.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",
|
package/src/cockpit.ts
CHANGED
|
@@ -46,7 +46,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
46
46
|
.ws .dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}
|
|
47
47
|
.ws.on .dot{background:var(--done)}
|
|
48
48
|
.wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
|
|
49
|
-
.toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px}
|
|
49
|
+
.toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px;background:none;cursor:pointer;font-family:var(--mono)}
|
|
50
50
|
.toggle:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
51
51
|
|
|
52
52
|
.layout{display:grid;grid-template-columns:270px 1fr;height:calc(100vh - 46px)}
|
|
@@ -183,6 +183,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
183
183
|
.pick-name:focus{outline:none;border-color:var(--brand)}
|
|
184
184
|
.pick-name::placeholder{color:var(--faint)}
|
|
185
185
|
|
|
186
|
+
.sec-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:7px;font-size:12.5px;color:var(--ink)}
|
|
187
|
+
.sec-row:hover{background:var(--surface2)}
|
|
188
|
+
.sec-row .snm{flex:1;font-family:var(--mono)}
|
|
189
|
+
.sec-row .shint{color:var(--faint);font-size:11px;font-family:var(--mono)}
|
|
190
|
+
.sec-row .sdel{color:var(--faint);border:none;background:none;cursor:pointer;font-size:14px;padding:0 4px}
|
|
191
|
+
.sec-row .sdel:hover{color:var(--failed)}
|
|
192
|
+
.leaf-h .lock{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
|
|
193
|
+
.leaf-h .lock:hover{color:var(--brand)}
|
|
194
|
+
.leaf-h .sendkey{font:inherit;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:1px 6px;width:150px;outline:none}
|
|
186
195
|
.lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
|
|
187
196
|
.tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
|
|
188
197
|
.tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
|
|
@@ -233,6 +242,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
233
242
|
</div>
|
|
234
243
|
<div class="right">
|
|
235
244
|
<span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
|
|
245
|
+
<button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">🔑 Secrets</button>
|
|
236
246
|
<a class="toggle" href="/" title="보드(모니터) 뷰로">← Board</a>
|
|
237
247
|
</div>
|
|
238
248
|
</header>
|
|
@@ -313,6 +323,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
313
323
|
</div>
|
|
314
324
|
</div>
|
|
315
325
|
|
|
326
|
+
<div class="modal" id="secretsModal">
|
|
327
|
+
<div class="pick" style="width:min(520px,92vw)">
|
|
328
|
+
<div class="pick-h"><span class="t">🔑 시크릿 (env 주입)</span><button class="x" id="secretsClose" title="닫기">×</button></div>
|
|
329
|
+
<div style="padding:10px 15px;font-size:11px;color:var(--faint);border-bottom:1px solid var(--line)">여기 등록한 값은 <b>새 세션</b>을 열 때 tmux env 로 주입됩니다(스크롤백에 안 남음). 그 안의 <code>claude</code>·스크립트가 env 에서 읽어 프롬프트가 안 뜹니다. 이미 열린 세션엔 새로 열어야 적용됩니다.</div>
|
|
330
|
+
<div class="pick-list" id="secretsList"></div>
|
|
331
|
+
<div class="pick-f" style="gap:7px">
|
|
332
|
+
<input class="pick-name" id="secName" placeholder="이름 (예: OPENAI_API_KEY)" autocomplete="off" style="flex:0 0 210px" />
|
|
333
|
+
<input class="pick-name" id="secVal" type="password" placeholder="값" autocomplete="off" />
|
|
334
|
+
<button class="go" id="secAdd">저장</button>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
|
|
316
339
|
<div class="toast" id="toast"></div>
|
|
317
340
|
|
|
318
341
|
<script src="/vendor/xterm.js"></script>
|
|
@@ -533,6 +556,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
533
556
|
? '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
|
|
534
557
|
+ '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
|
|
535
558
|
+ '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
|
|
559
|
+
+ '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">🔒</button>'
|
|
536
560
|
+ '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>'
|
|
537
561
|
: '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
|
|
538
562
|
var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
|
|
@@ -684,6 +708,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
684
708
|
|
|
685
709
|
// ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
|
|
686
710
|
$('panes').addEventListener('click', function(e){
|
|
711
|
+
var lockBtn=e.target.closest('[data-lock]');
|
|
712
|
+
if (lockBtn){ e.stopPropagation(); startSecretSend(lockBtn.getAttribute('data-lock'), lockBtn); return; }
|
|
687
713
|
var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
|
|
688
714
|
if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
|
|
689
715
|
setLeafFocus(leaf.dataset.leaf);
|
|
@@ -756,6 +782,52 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
756
782
|
$('sessionBtn').addEventListener('click', openSession);
|
|
757
783
|
$('sessionCta').addEventListener('click', openSession);
|
|
758
784
|
|
|
785
|
+
// ── (A) 시크릿 볼트 — 세션 env 주입 ──
|
|
786
|
+
function openSecrets(){ $('secretsModal').classList.add('on'); $('secName').value=''; $('secVal').value=''; loadSecrets(); }
|
|
787
|
+
function closeSecrets(){ $('secretsModal').classList.remove('on'); }
|
|
788
|
+
async function loadSecrets(){
|
|
789
|
+
try{ var d=await (await fetch('/api/secrets')).json(); var rows=d.secrets||[];
|
|
790
|
+
$('secretsList').innerHTML = rows.length ? rows.map(function(s){
|
|
791
|
+
return '<div class="sec-row"><span class="snm">'+esc(s.name)+'</span><span class="shint">'+esc(s.hint)+'</span><button class="sdel" data-del="'+esc(s.name)+'" title="삭제">×</button></div>';
|
|
792
|
+
}).join('') : '<div class="sec-row" style="color:var(--faint)">등록된 시크릿 없음 — 아래에서 추가</div>';
|
|
793
|
+
}catch(e){ $('secretsList').innerHTML='<div class="sec-row" style="color:var(--failed)">불러오기 실패</div>'; }
|
|
794
|
+
}
|
|
795
|
+
async function addSecret(){
|
|
796
|
+
var name=$('secName').value.trim(), value=$('secVal').value;
|
|
797
|
+
if(!name){ toast('이름을 입력하세요'); return; }
|
|
798
|
+
try{ var res=await fetch('/api/secrets',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:name,value:value})});
|
|
799
|
+
if(res.ok){ $('secName').value=''; $('secVal').value=''; toast('저장 · '+name+' (새 세션부터 적용)'); loadSecrets(); }
|
|
800
|
+
else{ var j=await res.json().catch(function(){return{};}); toast('저장 실패: '+(j.error||res.status)); }
|
|
801
|
+
}catch(e){ toast('저장 실패: '+e); }
|
|
802
|
+
}
|
|
803
|
+
$('secretsBtn').addEventListener('click', openSecrets);
|
|
804
|
+
$('secretsClose').addEventListener('click', closeSecrets);
|
|
805
|
+
$('secretsModal').addEventListener('click', function(e){ if(e.target===this) closeSecrets(); });
|
|
806
|
+
$('secAdd').addEventListener('click', addSecret);
|
|
807
|
+
$('secName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('secVal').focus(); } });
|
|
808
|
+
$('secVal').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); addSecret(); } });
|
|
809
|
+
$('secretsList').addEventListener('click', async function(e){ var b=e.target.closest('[data-del]'); if(!b) return;
|
|
810
|
+
if(!confirm('시크릿 삭제: '+b.dataset.del+' ?')) return;
|
|
811
|
+
try{ await fetch('/api/secrets/'+encodeURIComponent(b.dataset.del),{method:'DELETE'}); toast('삭제됨'); loadSecrets(); }catch(err){ toast('삭제 실패'); }
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// ── (B) 페인에 시크릿/비밀번호 전송 — 마스킹 입력을 그 페인 tmux stdin 으로 ──
|
|
815
|
+
function startSecretSend(leafId, btn){
|
|
816
|
+
var l=findLeaf(leafId); if(!l||l.tab==null){ toast('페인에 탭이 없습니다'); return; }
|
|
817
|
+
var t=tabs[l.tab]; if(!t||!t.ws||t.ws.readyState!==1){ toast('터미널이 연결되지 않았습니다'); return; }
|
|
818
|
+
var input=document.createElement('input'); input.className='sendkey'; input.type='password'; input.placeholder='비밀번호/키 + Enter';
|
|
819
|
+
btn.replaceWith(input); input.focus();
|
|
820
|
+
var done=false;
|
|
821
|
+
function finish(send){ if(done) return; done=true;
|
|
822
|
+
if(send){ try{ t.ws.send(JSON.stringify({t:'i',d:input.value+'\\r'})); }catch(e){} toast('페인에 전송(엔터 포함)'); }
|
|
823
|
+
var nb=document.createElement('button'); nb.className='lock'; nb.setAttribute('data-lock',leafId); nb.title='이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)'; nb.textContent='🔒';
|
|
824
|
+
try{ input.replaceWith(nb); }catch(e){}
|
|
825
|
+
}
|
|
826
|
+
input.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); finish(true); } else if(e.key==='Escape'){ finish(false); } });
|
|
827
|
+
input.addEventListener('blur', function(){ finish(false); });
|
|
828
|
+
input.addEventListener('click', function(e){ e.stopPropagation(); });
|
|
829
|
+
}
|
|
830
|
+
|
|
759
831
|
// ── 요청바: New(팬아웃) / Steer / Broadcast ──
|
|
760
832
|
var reqMode = 'new';
|
|
761
833
|
function setMode(m){
|
package/src/db/index.ts
CHANGED
|
@@ -90,6 +90,12 @@ export async function ensureSchema(): Promise<void> {
|
|
|
90
90
|
title TEXT NOT NULL,
|
|
91
91
|
created_at INTEGER DEFAULT (unixepoch())
|
|
92
92
|
);
|
|
93
|
+
CREATE TABLE IF NOT EXISTS secrets (
|
|
94
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
95
|
+
name TEXT NOT NULL UNIQUE,
|
|
96
|
+
value TEXT NOT NULL DEFAULT '',
|
|
97
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
98
|
+
);
|
|
93
99
|
`);
|
|
94
100
|
// 기존 DB 마이그레이션(멱등)
|
|
95
101
|
try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
|
package/src/db/schema.ts
CHANGED
|
@@ -107,3 +107,11 @@ export const agentEvents = sqliteTable('agent_events', {
|
|
|
107
107
|
payload: text('payload').notNull().default(''),
|
|
108
108
|
ts: integer('ts', { mode: 'timestamp' }),
|
|
109
109
|
});
|
|
110
|
+
|
|
111
|
+
/** 시크릿 볼트 — 세션/워크벤치 tmux 에 env 로 주입(API 키 프롬프트 제거). owner 전용, 평문 저장(≈.env). */
|
|
112
|
+
export const secrets = sqliteTable('secrets', {
|
|
113
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
114
|
+
name: text('name').notNull().unique(), // env 변수명 (예: OPENAI_API_KEY)
|
|
115
|
+
value: text('value').notNull().default(''),
|
|
116
|
+
createdAt: integer('created_at', { mode: 'timestamp' }),
|
|
117
|
+
});
|
package/src/orchestrator.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { ChildProcess } from 'node:child_process';
|
|
|
8
8
|
import { eq, inArray, and } from 'drizzle-orm';
|
|
9
9
|
import { config } from './config';
|
|
10
10
|
import { db } from './db';
|
|
11
|
-
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups } from './db/schema';
|
|
11
|
+
import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups, secrets } from './db/schema';
|
|
12
12
|
import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
|
|
13
13
|
import { broadcast } from './hub';
|
|
14
14
|
import { getProvider, type Provider } from './providers';
|
|
@@ -108,6 +108,20 @@ async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInser
|
|
|
108
108
|
broadcast({ type: 'run', runId, ...patch });
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* 시크릿 볼트 → tmux `new-session -e KEY=VAL` 인자로 변환(env 주입).
|
|
113
|
+
* 이러면 세션 안의 claude·스크립트가 env 에서 키를 읽어 대화형 프롬프트가 안 뜬다.
|
|
114
|
+
* env 변수명 규칙에 맞는 것만, 값은 셸-인용. 결과는 선행 공백 포함(없으면 빈 문자열).
|
|
115
|
+
*/
|
|
116
|
+
export async function secretEnvArgs(): Promise<string> {
|
|
117
|
+
try {
|
|
118
|
+
const rows = await db.select().from(secrets);
|
|
119
|
+
const ok = rows.filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s.name));
|
|
120
|
+
if (!ok.length) return '';
|
|
121
|
+
return ' ' + ok.map((s) => `-e ${shq(`${s.name}=${s.value}`)}`).join(' ');
|
|
122
|
+
} catch { return ''; }
|
|
123
|
+
}
|
|
124
|
+
|
|
111
125
|
// 실행 중 run 의 자식 프로세스(stop 용). stoppedRuns = 사용자가 멈춘 run 표식.
|
|
112
126
|
const liveChildren = new Map<number, ChildProcess>();
|
|
113
127
|
const stoppedRuns = new Set<number>();
|
|
@@ -437,8 +451,9 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
|
|
|
437
451
|
|
|
438
452
|
// 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치).
|
|
439
453
|
// export LANG: 이 명령이 tmux 서버를 처음 띄우는 경우(특히 원격) C 로케일로 뜨면 CJK 가 깨진다.
|
|
454
|
+
const runEnv = await secretEnvArgs();
|
|
440
455
|
await runShellOn(ctx.machine,
|
|
441
|
-
`export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
|
|
456
|
+
`export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d${runEnv} -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
|
|
442
457
|
|
|
443
458
|
await setRun(runId, { status: 'running' });
|
|
444
459
|
await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
|
|
@@ -1006,14 +1021,15 @@ export async function openWorkbench(repoId: number, title: string, root = false)
|
|
|
1006
1021
|
|
|
1007
1022
|
// export LANG: tmux 서버 첫 기동이 C 로케일이면 세션 셸의 CJK 입력·표시가 깨진다.
|
|
1008
1023
|
// 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확 일치만.
|
|
1024
|
+
const wbEnv = await secretEnvArgs(); // 시크릿 볼트 → env 주입
|
|
1009
1025
|
const prep = await runShellOn(
|
|
1010
1026
|
machine,
|
|
1011
1027
|
root
|
|
1012
1028
|
? `export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1013
|
-
` && tmux new-session -d -s ${shq(session)} -c ${shq(repo.path)}`
|
|
1029
|
+
` && tmux new-session -d${wbEnv} -s ${shq(session)} -c ${shq(repo.path)}`
|
|
1014
1030
|
: `export LANG=${shq(config.lang)}; mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
|
|
1015
1031
|
` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1016
|
-
` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
|
|
1032
|
+
` && tmux new-session -d${wbEnv} -s ${shq(session)} -c ${shq(wtPath)}`,
|
|
1017
1033
|
20000,
|
|
1018
1034
|
);
|
|
1019
1035
|
if (!prep.ok) {
|
|
@@ -1062,10 +1078,11 @@ export async function openSessionAt(machineSlug: string, path: string, title: st
|
|
|
1062
1078
|
broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'session', branch: '', filesChanged: 0 });
|
|
1063
1079
|
|
|
1064
1080
|
const session = `coxpit-r${runId}`;
|
|
1081
|
+
const sEnv = await secretEnvArgs(); // 시크릿 볼트 → env 주입
|
|
1065
1082
|
const prep = await runShellOn(
|
|
1066
1083
|
machine,
|
|
1067
1084
|
`export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
|
|
1068
|
-
` && tmux new-session -d -s ${shq(session)} -c ${shq(dir)}`,
|
|
1085
|
+
` && tmux new-session -d${sEnv} -s ${shq(session)} -c ${shq(dir)}`,
|
|
1069
1086
|
15000,
|
|
1070
1087
|
);
|
|
1071
1088
|
if (!prep.ok) {
|
package/src/server.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { config } from './config';
|
|
17
17
|
import { readSettings, writeSettings } from './settings';
|
|
18
18
|
import { db } from './db';
|
|
19
|
-
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
|
|
19
|
+
import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
|
|
20
20
|
import { BOOKMARKLET_JS } from './design';
|
|
21
21
|
import { runShellOn, shq } from './exec';
|
|
22
22
|
import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt } from './orchestrator';
|
|
@@ -1034,6 +1034,29 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1034
1034
|
return reply.code(201).send(res);
|
|
1035
1035
|
});
|
|
1036
1036
|
|
|
1037
|
+
// ── 시크릿 볼트 — 세션 tmux 에 env 로 주입(API 키 프롬프트 제거). owner 전용. ──
|
|
1038
|
+
// GET 은 값 전체를 절대 반환하지 않음(이름 + 끝 4자 힌트만).
|
|
1039
|
+
app.get('/api/secrets', async () => {
|
|
1040
|
+
const rows = await db.select().from(secrets);
|
|
1041
|
+
return { secrets: rows.map((s) => ({ name: s.name, hint: s.value ? '••••' + s.value.slice(-4) : '(empty)' })) };
|
|
1042
|
+
});
|
|
1043
|
+
app.post('/api/secrets', async (req, reply) => {
|
|
1044
|
+
const b = (req.body ?? {}) as { name?: string; value?: string };
|
|
1045
|
+
const name = (b.name ?? '').trim();
|
|
1046
|
+
const value = String(b.value ?? '');
|
|
1047
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return reply.code(400).send({ error: 'invalid env var name (A-Z, 0-9, _)' });
|
|
1048
|
+
if (value.length > 8192) return reply.code(400).send({ error: 'value too long (max 8192)' });
|
|
1049
|
+
const existing = await db.select().from(secrets).where(eq(secrets.name, name)).limit(1);
|
|
1050
|
+
if (existing[0]) await db.update(secrets).set({ value }).where(eq(secrets.name, name));
|
|
1051
|
+
else await db.insert(secrets).values({ name, value });
|
|
1052
|
+
return { ok: true, name };
|
|
1053
|
+
});
|
|
1054
|
+
app.delete('/api/secrets/:name', async (req, reply) => {
|
|
1055
|
+
const name = String((req.params as { name: string }).name);
|
|
1056
|
+
await db.delete(secrets).where(eq(secrets.name, name));
|
|
1057
|
+
return reply.code(200).send({ ok: true });
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1037
1060
|
// 자유 세션 — 임의 폴더에서 tmux 셸(프로젝트 비소속, 가상 Sessions 버킷).
|
|
1038
1061
|
app.post('/api/session', async (req, reply) => {
|
|
1039
1062
|
const b = (req.body ?? {}) as { machineSlug?: string; path?: string; title?: string };
|