diff --git a/.gitignore b/.gitignore index dbf126d..835f82c 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ server.log # Worktrees .worktrees/ + +# Docs — archived session/design records +docs/archive/ diff --git a/CLAUDE.md b/CLAUDE.md index 264a7db..d4d2ee4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,18 @@ npm run build:web # Build web app npm run package:desktop # Package macOS DMG ``` +## Electron Bundle Sync (필수) + +`tools/server/`, `tools/electron/`, `packages/shared/` 파일을 수정한 경우, **반드시** Electron 번들을 리빌드해야 한다: + +```bash +npx esbuild tools/electron/main.ts --bundle --platform=node --outdir=dist/electron --external:electron --packages=external --alias:@vync/shared=./packages/shared/src/index.ts --sourcemap +``` + +- **왜**: Electron dev 모드(`npm run dev:desktop`)는 `dist/electron/main.js` 번들을 실행함. 소스만 수정하고 번들을 안 빌드하면 변경이 반영되지 않음 +- **언제**: 위 디렉토리 파일 수정 후, 커밋 전에 반드시 실행 +- **DMG 리패키징**: `npm run package:desktop` — 유저가 명시적으로 요청할 때만 실행 (2-3분 소요, 코드 서명+공증 포함) + ## Claude Code Plugin **Install**: `npm install` (postinstall → marketplace 등록 + 캐시 동기화) @@ -119,4 +131,4 @@ feat/xyz ●──● ●──● (short-lived feature branches) - Frontend: onChange → 300ms debounce → PUT /api/sync?file= - WebSocket: file-scoped broadcast (A.vync changes only reach A.vync clients) - Hub WS: no `?file=` param → receives file registration/unregistration events for multi-tab UI -- Multi-tab UI: TabBar component, active tab only mounted, `+` dropdown for reopening closed tabs +- Multi-tab UI: TabBar component, active tab only mounted, `+` dropdown with "Reopen" (closed tabs) and "Open" (discovered unregistered `.vync` files via `GET /api/files/discover`) diff --git a/apps/web/src/app/app.tsx b/apps/web/src/app/app.tsx index dd7403f..73ba35b 100644 --- a/apps/web/src/app/app.tsx +++ b/apps/web/src/app/app.tsx @@ -31,6 +31,7 @@ export function App() { const [tabs, setTabs] = useState([]); const [activeFilePath, setActiveFilePath] = useState(null); const [registeredFiles, setRegisteredFiles] = useState([]); + const [discoveredFiles, setDiscoveredFiles] = useState([]); const tabsRef = useRef(tabs); tabsRef.current = tabs; @@ -126,15 +127,50 @@ export function App() { setActiveFilePath(filePath); }, []); + const handleDropdownOpen = useCallback(async () => { + try { + const res = await fetch('/api/files/discover'); + if (res.ok) { + const data = await res.json(); + setDiscoveredFiles(data.files || []); + } + } catch { + setDiscoveredFiles([]); + } + }, []); + + const handleDiscoverFile = useCallback( + async (filePath: string) => { + try { + const res = await fetch('/api/files', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filePath }), + }); + if (res.ok) { + const data = await res.json(); + handleAddFile(data.filePath); + setDiscoveredFiles([]); + } + } catch (err) { + console.error('[vync] Failed to register discovered file:', err); + } + }, + [handleAddFile] + ); + return (
{activeFilePath ? (
diff --git a/apps/web/src/app/tab-bar.scss b/apps/web/src/app/tab-bar.scss index e13be2a..4f0ac18 100644 --- a/apps/web/src/app/tab-bar.scss +++ b/apps/web/src/app/tab-bar.scss @@ -4,9 +4,14 @@ height: 36px; background: #f0f0f0; border-bottom: 1px solid #ddd; - overflow-x: auto; flex-shrink: 0; +} +.vync-tab-scroll { + display: flex; + align-items: stretch; + min-width: 0; + overflow-x: auto; &::-webkit-scrollbar { height: 0; } } @@ -70,6 +75,21 @@ min-width: 180px; z-index: 100; padding: 4px 0; + max-height: 300px; + overflow-y: auto; +} + +.vync-tab-dropdown__section { + padding: 4px 12px; + font-size: 11px; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + &:not(:first-child) { + border-top: 1px solid #eee; + margin-top: 2px; + padding-top: 6px; + } } .vync-tab-dropdown__item { diff --git a/apps/web/src/app/tab-bar.tsx b/apps/web/src/app/tab-bar.tsx index 762ba50..6c0f73d 100644 --- a/apps/web/src/app/tab-bar.tsx +++ b/apps/web/src/app/tab-bar.tsx @@ -6,18 +6,24 @@ interface TabBarProps { tabs: TabInfo[]; activeFilePath: string | null; registeredFiles: string[]; + discoveredFiles: string[]; onTabClick: (filePath: string) => void; onTabClose: (filePath: string) => void; onAddFile: (filePath: string) => void; + onDiscoverFile: (filePath: string) => void; + onDropdownOpen: () => void; } export function TabBar({ tabs, activeFilePath, registeredFiles, + discoveredFiles, onTabClick, onTabClose, onAddFile, + onDiscoverFile, + onDropdownOpen, }: TabBarProps) { const [dropdownOpen, setDropdownOpen] = useState(false); const dropdownRef = useRef(null); @@ -26,7 +32,10 @@ export function TabBar({ useEffect(() => { if (!dropdownOpen) return; const handler = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { setDropdownOpen(false); } }; @@ -40,52 +49,90 @@ export function TabBar({ return (
- {tabs.map((tab) => ( -
onTabClick(tab.filePath)} - > - {tab.label} - -
- ))} + {tab.label} + +
+ ))} +
- setDropdownOpen(!dropdownOpen)}>+ + { + const willOpen = !dropdownOpen; + setDropdownOpen(willOpen); + if (willOpen) onDropdownOpen(); + }} + > + + + {dropdownOpen && (
- {unopenedFiles.length > 0 ? ( - unopenedFiles.map((fp) => { - const parts = fp.split('/'); - const label = parts[parts.length - 1] || fp; - return ( -
{ - onAddFile(fp); - setDropdownOpen(false); - }} - > - {label} -
- ); - }) - ) : ( + {unopenedFiles.length > 0 && ( + <> +
Reopen
+ {unopenedFiles.map((fp) => { + const parts = fp.split('/'); + const label = parts[parts.length - 1] || fp; + return ( +
{ + onAddFile(fp); + setDropdownOpen(false); + }} + > + {label} +
+ ); + })} + + )} + {discoveredFiles.length > 0 && ( + <> +
Open
+ {discoveredFiles.map((fp) => { + const parts = fp.split('/'); + const label = parts[parts.length - 1] || fp; + return ( +
{ + onDiscoverFile(fp); + setDropdownOpen(false); + }} + > + {label} +
+ ); + })} + + )} + {unopenedFiles.length === 0 && discoveredFiles.length === 0 && (
- No more files. + No files found.
- Use vync open to register new files. + Use vync open <file> to add files.
)}
diff --git a/commands/vync.md b/commands/vync.md index c2bc8df..843bb7c 100644 --- a/commands/vync.md +++ b/commands/vync.md @@ -27,7 +27,7 @@ node "$VYNC_HOME/bin/vync.js" [args] - `read [file]` — diff 실행 → 의미적 번역 - `update ` — diff 확인 → 맥락+지시 기반 .vync 수정 + 서버 열기 -For these, use the Agent tool with `subagent_type: "vync-translator"`. +For these, use the Agent tool with `subagent_type: "vync:vync-translator"`. ## Sub-agent 호출 절차 @@ -48,7 +48,7 @@ For these, use the Agent tool with `subagent_type: "vync-translator"`. ``` Agent({ description: "Vync create visualization", - subagent_type: "vync-translator", + subagent_type: "vync:vync-translator", mode: "bypassPermissions", prompt: "## 작업: Create\n파일: \n\n## 대화 맥락\n<메인 세션이 요약한 현재 논의 상황, 2-5문장>\n\n## 지시\n<구체적 지시 or '맥락에 맞게 판단해서 시각화해줘'>\n<선호하는 유형이 있으면: 'mindmap 형식으로' 등>" }) @@ -66,7 +66,7 @@ Agent({ ``` Agent({ description: "Vync read + translate diff", - subagent_type: "vync-translator", + subagent_type: "vync:vync-translator", mode: "bypassPermissions", prompt: "## 작업: Read\n파일: \n\n## 대화 맥락\n<현재 논의 상황>\n\n## 유저 피드백 (diff)\n\n\n## 지시\n위 변경사항을 대화 맥락에 비춰 의미적으로 번역해줘." }) @@ -84,7 +84,7 @@ Agent({ ``` Agent({ description: "Vync update visualization", - subagent_type: "vync-translator", + subagent_type: "vync:vync-translator", mode: "bypassPermissions", prompt: "## 작업: Update\n파일: \n\n## 대화 맥락\n<현재 논의 상황>\n\n## 유저 피드백 (diff)\n\n\n## 지시\n<구체적 수정 지시>" }) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c06b2aa..54f4c7b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,19 +81,40 @@ 단일 사용자 시나리오에서는 동시 편집이 드물다. 발생하더라도 양쪽이 즉시 최종 상태로 동기화되므로 사용자가 곧바로 재편집 가능. -### 2.4 API 엔드포인트 (→ D-014) +### 2.4 Diff 파이프라인 (→ D-015, D-016) + +``` +vync diff (프로그래밍적 diff) + → .lastread 스냅샷 vs 현재 .vync 파일 비교 + → ID 기반 구조적 diff (추가/삭제/변경 노드 감지) + → 레이아웃 필드 무시 (points, width, height 등 — 레이아웃 엔진이 관리) + → 트리 형태 텍스트 출력 (마인드맵은 계층 구조 유지) + → .lastread 스냅샷 갱신 (--no-snapshot으로 억제 가능) +``` + +**3단계 파이프라인 (D-015)**: +``` +Stage 1: vync diff (코드) — .lastread vs .vync 프로그래밍적 비교 → 정밀한 구조 변경 +Stage 2: Sub-agent (LLM) — 맥락 + diff → 의미적 번역 + 시각화 전략 판단/실행 +Stage 3: 메인 세션 — Sub-agent prose + 대화 맥락 → 인사이트 +``` + +**Sub-agent 역할 (D-016)**: 시각화 전문가 — 대화 맥락과 diff를 이해하고 시각화 전략을 자율 판단하여 .vync 파일을 작성/수정한다. + +### 2.5 API 엔드포인트 (→ D-014, file discovery 포함) | 엔드포인트 | 메서드 | 설명 | |-----------|--------|------| -| `/api/health` | GET | 서버 상태 (`{ version: 2, mode: 'hub', fileCount }`) | +| `/api/health` | GET | 서버 상태 (`{ version: 2, mode: 'hub', pid, fileCount }`) | | `/api/files` | GET | 등록된 파일 목록 (`{ files: ['/path/to/a.vync', ...] }`) | | `/api/files` | POST | 파일 등록 (`{ filePath }` → `201 Created` / `200 OK`) | +| `/api/files/discover` | GET | 미등록 `.vync` 파일 탐색 (`{ files: [...] }`, 최대 100개) | | `/api/files?file=` | DELETE | 특정 파일 해제 | | `/api/files?all=true` | DELETE | 전체 파일 해제 | | `/api/sync?file=` | GET | 파일 내용 읽기 (VyncFile JSON) | | `/api/sync?file=` | PUT | 파일 내용 쓰기 (VyncFile JSON body) | -### 2.5 WebSocket 프로토콜 (→ D-014) +### 2.6 WebSocket 프로토콜 (→ D-014) **파일 스코프 WS** (`ws://localhost:3100/ws?file=`): - 특정 파일의 변경 알림 수신 @@ -118,7 +139,7 @@ | 파일 포맷 | .vync (JSON) | D-005 | | CLI | Node.js (bin 스크립트) | D-006 | | 패키지 매니저 | npm + nx monorepo | D-011 | -| 데스크톱 | Electron + electron-builder (macOS DMG) | D-012 | +| 데스크톱 | Electron + electron-builder (macOS DMG, 코드 서명 + 공증) | D-012 | | 테스트 | Vitest | Vite 생태계, TS 네이티브 | --- @@ -337,7 +358,7 @@ Vync/ # nx monorepo (Drawnix 포크 기반) │ ├── main.ts # CLI 진입점 (init/open/close/stop/diff 라우팅) [VYNC 수정: Phase 8, diff 추가] │ ├── init.ts # vync init: 빈 .vync 파일 생성 │ ├── open.ts # vync open/close/stop: 허브 모드 + PID JSON 포맷 [VYNC 수정: Phase 8] -│ ├── diff.ts # vync diff: 프로그래밍적 diff (.lastread vs .vync) [VYNC 추가: D-015] +│ ├── diff.ts # vync diff: ID 기반 구조적 diff (.lastread vs .vync) [VYNC 추가: D-015] │ ├── resolve.ts # resolveVyncPath(): 경로 해석 (bare name → .vync/ 하위) │ └── __tests__/ │ ├── init.test.ts # init 유닛 테스트 @@ -449,20 +470,34 @@ vync init /tmp/test → /tmp/test.vync (절대경로) ``` `bin/vync.js`가 `VYNC_CALLER_CWD` 환경변수로 호출자의 원래 CWD를 전달. -**서버 시작 (`vync open `)** — 허브 모드 (2-state 감지): +**서버 시작 (`vync open `)** — 허브 모드 (2-state 감지 + recovery): ``` 1. 경로 해석 (resolveVyncPath) → 파일 존재 확인 → 없으면 에러 메시지 + 종료 -2. 기존 서버 상태 확인 (2-state): - - PID 파일 읽기 → 프로세스 존재 확인 (kill 0) → HTTP 헬스체크 (GET /api/health) - - running: 서버 실행 중 → POST /api/files로 파일 등록 + 브라우저 열기 - - not-running: 서버 없음 → 새 서버 시작 +2. 기존 서버 상태 확인 (2-state + port probe fallback): + a. PID 파일 읽기 → 프로세스 존재 확인 (kill 0) → HTTP 헬스체크 (GET /api/health) + b. PID 파일 없음 또는 PID 프로세스 죽음 → 포트 프로브 (GET /api/health on :3100) + - 포트에서 Vync 서버 발견 (version: 2 + pid) → PID 파일 복구 + 재사용 + c. running: 서버 실행 중 → POST /api/files로 파일 등록 + 브라우저 열기 + d. not-running: 서버 없음 → 새 서버 시작 3. [Electron 모드 — 기본] electron dist/electron/main.js 을 detached spawn [tsx 데몬 모드 — 폴백] tsx로 server.ts를 detached 자식 프로세스로 spawn [포그라운드 모드 — --foreground] 현재 프로세스 내에서 startServer() 직접 호출 4. 300ms 간격 폴링으로 서버 준비 대기 (최대 10초) + - 폴링 성공 시 health.pid vs childPid 비교 → ghost server 감지 시 PID 파일 교정 + - registerFile 호출 (Electron/daemon 모두 동일) 5. PID 파일에 ServerInfo JSON 기록 ``` +**Electron EADDRINUSE Recovery**: +``` +Electron이 startServer() 시 EADDRINUSE 에러 발생 + → GET /api/health on :3100 (AbortSignal 2초 타임아웃) + → version: 2 확인 → 기존 서버 재사용 (shutdown: no-op) + → POST /api/files로 파일 등록 + → 기존 서버를 죽이지 않음 (다른 세션의 서버일 수 있음) + → version 불일치 또는 응답 없음 → 에러 다이얼로그 + 종료 +``` + **파일 해제 (`vync close [file]`)** — 허브 모드: ``` 1. 특정 파일: DELETE /api/files?file= → 해당 파일만 해제 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e044e3f..b81058c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -352,7 +352,7 @@ Stage 3: 메인 세션 — Sub-agent prose + 대화 맥락 → 인사이트 **근거**: 각 단계가 가장 잘하는 것을 담당. 코드=정밀 계산, LLM=의미 번역, 메인=맥락 해석. **PoC 검증**: H1(diff 의미 번역) PASS, H2(맥락 효과) PASS (2026-03-11) -**설계 문서**: `docs/plans/2026-03-11-diff-pipeline-redesign.md` +**설계 문서**: `docs/archive/2026-03-11-diff-pipeline-redesign.md` **재검토 조건**: Sub-agent 번역 품질이 실사용에서 불충분할 경우 @@ -375,6 +375,6 @@ Sub-agent의 2가지 역할: **입력**: 대화 맥락 + 프로그래밍적 diff + 지시 **근거**: 메인 세션은 대화에 집중, Sub-agent는 시각화에 집중. 역할 분리 명확화. **PoC 검증**: H3(자율 시각화 판단) PASS — mindmap 자율 선택, 16노드/3단계, validate 통과 (2026-03-11) -**설계 문서**: `docs/plans/2026-03-11-diff-pipeline-redesign.md` +**설계 문서**: `docs/archive/2026-03-11-diff-pipeline-redesign.md` **재검토 조건**: 자율 판단 품질이 실사용에서 불충분할 경우 diff --git a/docs/FUTURE.md b/docs/FUTURE.md index c1e8d2a..976dcd0 100644 --- a/docs/FUTURE.md +++ b/docs/FUTURE.md @@ -123,7 +123,7 @@ D-008에서 Last Write Wins로 결정. 후속 개선 옵션: | 항목 | 설명 | |------|------| | 배포/패키징 | Electron DMG 패키징 구현 완료 (→ D-012). 코드 서명/공증 완료 (Developer ID Application + notarytool, 2026-03-11). 추가: 자동 업데이트, `npx vync`/npm 발행 | -| 다중 파일 | **1단계 완료** (Phase 8, → D-014): Hub Server + 멀티 윈도우. **2단계 완료** (Phase 9): 멀티 탭 UI ([설계](../docs/archive/2026-03-09-multi-tab-ui-design.md)). 후속: 대시보드, 파일 간 링크 | +| 다중 파일 | **1단계 완료** (Phase 8, → D-014): Hub Server + 멀티 윈도우. **2단계 완료** (Phase 9): 멀티 탭 UI ([설계](./archive/2026-03-09-multi-tab-ui-design.md)). 후속: 대시보드, 파일 간 링크 | | 보안 | 기본 보안 구현 완료 (Phase 8): validateFilePath(allowlist + .vync + realpath) + Host 헤더 검증 + CORS + WS Origin 검증. 추가: 디렉토리 접근 제한 고도화 | | `vync watch` | UI 없이 파일 감시 데몬 (자동 변환 파이프라인용) | | 업스트림 (Drawnix) 추적 | 주기적 upstream diff, 핵심 변경 최소화 | diff --git a/docs/PLAN.md b/docs/PLAN.md index ef2419c..027f393 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -7,7 +7,12 @@ ## 현재 상태 -**Phase**: 9 구현 완료 (멀티 탭 UI) — 2026-03-09 +**Phase**: Post-MVP 안정화 — 2026-03-12 +- Phase 1~9 (MVP): 완료 (2026-03-07 ~ 2026-03-09) +- Diff Pipeline (D-015/D-016): 완료 (2026-03-11) +- Server Lifecycle Fix (PR #10): 완료 (2026-03-11) +- macOS 코드 서명 + 공증: 완료 (2026-03-11) +- Tab Bar "+" 버튼 수정 (PR #11): 구현 완료, PR 대기 (2026-03-12) --- @@ -250,6 +255,99 @@ --- +## Diff Pipeline (D-015/D-016) + +**목표**: 프로그래밍적 diff 엔진으로 .vync 파일 변경을 정밀하게 감지하고, Sub-agent를 시각화 전문가로 역할 확장한다. +**의존**: Phase 7 (Sub-agent), Phase 9 (멀티 탭 UI) 완료 +**설계**: `docs/archive/2026-03-11-diff-pipeline-redesign.md` +**PoC**: `docs/archive/2026-03-11-diff-pipeline-poc.md` + `docs/archive/2026-03-11-diff-pipeline-poc-results.md` + +- [x] DP.1 Diff 엔진 (`tools/cli/diff.ts`) — ID 기반 구조적 diff (.lastread vs .vync), 트리 출력, 레이아웃 필드 무시 +- [x] DP.2 CLI `vync diff` 서브커맨드 — `--no-snapshot` 옵션, .lastread 스냅샷 관리 +- [x] DP.3 Diff 유닛 테스트 — 16개 테스트 PASS (diff.test.ts) +- [x] DP.4 Sub-agent 역할 재설계 — vync-translator를 "시각화 전문가"로 확장 (D-016) +- [x] DP.5 커맨드 흐름 재설계 — `/vync read`에 맥락+diff 입력, `/vync update`에 맥락 기반 지시 +- [x] DP.6 PoC 검증 — H1(diff 의미 번역) PASS, H2(맥락 효과) PASS, H3(자율 시각화 판단) PASS +- [x] DP.7 E2E 검증 — 6/6 PASS (create→브라우저수정→diff→read→update→diff 전체 사이클) +- [x] DP.8 플러그인 캐시 동기화 완료 + +**완료 기준**: +- [x] `vync diff ` 이 .lastread 대비 구조적 변경 보고 +- [x] Sub-agent가 맥락+diff를 이해하고 자율적 시각화 전략 판단 +- [x] 67개 전체 테스트 PASS (diff 16개 포함) + +**PR**: #9 (feat/diff-pipeline → develop → main) + +--- + +## Server Lifecycle Fix (PR #10) + +**목표**: Electron EADDRINUSE recovery + CLI/Server identity 검증으로 포트 충돌 시 자동 복구. +**설계**: `docs/archive/2026-03-10-server-lifecycle-fix.md` + +- [x] SL.1 Health endpoint에 `pid` 추가 — 서버 identity 검증 기반 +- [x] SL.2 Electron EADDRINUSE recovery — 기존 서버 감지 시 재사용 (shutdown: no-op) +- [x] SL.3 CLI `isServerRunning` 포트 프로브 — PID 파일 없어도 포트에서 Vync 서버 발견 + PID 파일 복구 +- [x] SL.4 `runElectron`에 `registerFile` 추가 — `runDaemon`과의 비대칭 해소 + ghost server identity 검증 +- [x] SL.5 통합 E2E 검증 — 정상 시작, ghost 서버 recovery, Electron EADDRINUSE recovery 시나리오 + +**완료 기준**: +- [x] PID 파일 없어도 포트 프로브로 기존 서버 발견 + 재사용 +- [x] Electron EADDRINUSE 시 기존 서버에 연결 (서버 종료 안 함) +- [x] runElectron/runDaemon 대칭성 확보 (registerFile 호출) +- [x] 전체 테스트 PASS + +**PR**: #10 (fix/server-lifecycle → develop → main) + +--- + +## macOS 코드 서명 + 공증 + +**목표**: macOS Gatekeeper를 통과하는 서명된 DMG를 빌드한다. + +- [x] CS.1 Developer ID Application 인증서 설정 (4Y9H392BWW) +- [x] CS.2 notarytool Keychain profile 생성 (`vync-notarize`) +- [x] CS.3 electron-builder.yml에 notarize 설정 추가 +- [x] CS.4 package.json에 환경변수 (APPLE_KEYCHAIN_PROFILE, APPLE_TEAM_ID) 설정 +- [x] CS.5 `npm run package:desktop` → 서명 + 공증 자동화 검증 +- [x] CS.6 Gatekeeper 통과 확인 (`source=Notarized Developer ID`) + +**완료 기준**: +- [x] `dist/packages/Vync-0.0.2-arm64.dmg` (121MB) 빌드 성공 +- [x] macOS Gatekeeper accepted, `source=Notarized Developer ID` + +**커밋**: `588fd97` (chore(desktop): add macOS code signing and notarization config) + +--- + +## Fix: Tab Bar "+" 버튼 (구현 완료, PR 대기) + +**목표**: "+" 버튼이 정상 동작하여 사용자가 디스크의 미등록 .vync 파일을 브라우저에서 직접 열 수 있게 한다. +**브랜치**: `fix/tab-add-button` +**계획**: `docs/plans/2026-03-12-fix-tab-add-button.md` + +### Bug 1: CSS overflow 클리핑 +- [x] TB.1 SCSS — `.vync-tab-scroll` 분리 (overflow-x: auto를 탭 스크롤 영역으로 이동) +- [x] TB.2 TSX — 탭 바 DOM에 scroll wrapper 추가 +- [x] TB.3 브라우저에서 드롭다운 표시 확인 + +### Bug 2: 파일 목록 항상 비어있음 +- [x] TB.4 서버 `GET /api/files/discover` 엔드포인트 테스트 작성 (5개) +- [x] TB.5 서버 `GET /api/files/discover` 엔드포인트 구현 +- [x] TB.6 TabBar props 확장 + App discovery state/handlers 추가 +- [x] TB.7 드롭다운 두 섹션 (Reopen/Open) 렌더링 +- [x] TB.8 섹션 헤더 스타일 + 드롭다운 max-height + +### 검증 +- [x] TB.9 TypeScript 컴파일 확인 +- [x] TB.10 전체 테스트 PASS (72개) +- [ ] TB.11 E2E 수동 검증 (6개 시나리오) + +### 남은 작업 +- E2E 수동 검증 후 PR 생성 (fix/tab-add-button → develop) + +--- + ## 리스크 | 리스크 | 영향 | 완화 방안 | 평가 시점 | diff --git a/docs/WRAP.md b/docs/WRAP.md index 70aa5ea..e27b6a8 100644 --- a/docs/WRAP.md +++ b/docs/WRAP.md @@ -101,5 +101,5 @@ - 결정 번호는 순차 증가: D-001, D-002, ... - 미결 질문 번호는 순차 증가: Q-001, Q-002, ... - 결정 상태: `확정` | `재검토` | `폐기` -- Phase 상태: Phase 번호 + 설명 (예: "Phase 1 (Drawnix 포크 진행 중)") +- Phase 상태: Phase 번호 + 설명 (예: "Phase 1 (Drawnix 포크 진행 중)") 또는 Post-MVP 단계명 (예: "Post-MVP 안정화") - 문서 간 참조는 `→ D-0XX` 또는 `[문서명](./파일명)` 형식 diff --git a/docs/archive/2026-03-07-phase4-claude-plugin-design.md b/docs/archive/2026-03-07-phase4-claude-plugin-design.md deleted file mode 100644 index 2aa574c..0000000 --- a/docs/archive/2026-03-07-phase4-claude-plugin-design.md +++ /dev/null @@ -1,272 +0,0 @@ -# Phase 4: Claude Code 통합 플러그인 설계 - -> 2026-03-07 확정. Claude Code의 전역 확장 시스템을 통해 .vync 파일의 전체 라이프사이클을 관리. - ---- - -## 목표 - -Claude Code 세션 안에서 .vync 파일의 **생성 -> 서버 시작 -> 편집 -> 검증 -> 서버 종료** 전체 라이프사이클을 관리하는 통합 도구 구축. - -- 스코프: **전역** (`~/.claude/`) -- 서버 관리: **플러그인(Command)에서 관리**, CLI를 기반 레이어로 사용 -- 검증: **PostToolUse hook으로 자동** - ---- - -## 컴포넌트 (5개) - -| # | 컴포넌트 | 유형 | 역할 | 스코프 | -|---|---------|------|------|--------| -| 1 | `bin/vync.js` + `src/cli/` | CLI | 기반 레이어. 서버 시작/종료, 파일 생성 | npm link (전역) | -| 2 | `vync-editing` | Skill | 지식 레이어. 편집 가이드 + 검증 스크립트 + 예시 | ~/.claude/skills/ | -| 3 | `/vync` | Command | 유틸리티. CLI thin wrapper (init/open/stop/read) | ~/.claude/commands/ | -| 4 | `/vync-create` | Command | 핵심 진입점. 다이어그램 생성 (Skill 트리거) | ~/.claude/commands/ | -| 5 | PostToolUse + SessionEnd | Hooks | 자동 검증 + 서버 정리 | ~/.claude/settings.json | - ---- - -## 의존성 그래프 - -``` -[Skill: vync-editing] ← 독립 (의존성 0) - ^ ^ - | | - | 참조 | scripts/ 사용 - | | -[Cmd: /vync-create] [Hook: PostToolUse] - [Hook: SessionEnd] -[Cmd: /vync] --> [CLI: bin/vync.js] --> [src/server/] -``` - -**규칙**: -- Skill은 순수 지식 + 유틸리티. 다른 것에 의존하지 않음. -- CLI는 Vync 서버 코드에 의존하지만, 플러그인 시스템과는 독립. -- Commands/Hooks는 CLI 또는 Skill에만 의존. - ---- - -## 파일 구조 - -``` -Vync/ -├── bin/vync.js # CLI 진입점 (shebang, process.argv) -├── src/cli/ -│ ├── init.ts # vync init: 빈 .vync 파일 생성 -│ └── open.ts # vync open: 서버 시작 + 브라우저 열기 -│ -├── claude-plugin/ # Claude Code 플러그인 소스 -│ ├── install.sh # ~/.claude/에 심볼릭 링크 + 설정 머지 -│ ├── uninstall.sh # 정리 -│ │ -│ ├── skills/ -│ │ └── vync-editing/ -│ │ ├── SKILL.md # 트리거 + 개요 + 편집 워크플로우 -│ │ ├── references/ -│ │ │ ├── mindmap.md # 마인드맵 상세 가이드 -│ │ │ ├── geometry.md # 도형 상세 가이드 -│ │ │ ├── arrow-line.md # 연결선 + 바인딩 가이드 -│ │ │ └── coordinates.md # 좌표계 + 레이아웃 -│ │ ├── scripts/ -│ │ │ ├── validate.js # JSON Schema 검증 (hook에서 사용) -│ │ │ └── generate-id.js # idCreator(5) 유틸리티 -│ │ └── assets/ -│ │ ├── schema.json # .vync JSON Schema -│ │ ├── mindmap.vync # 마인드맵 예시 -│ │ └── flowchart.vync # 플로우차트 예시 -│ │ -│ ├── commands/ -│ │ ├── vync.md # /vync init|open|stop|read -│ │ └── vync-create.md # /vync-create -│ │ -│ └── hooks.json # PostToolUse + SessionEnd 설정 -│ -└── .vync.schema.json # JSON Schema (프로젝트 루트 복사본) -``` - ---- - -## 컴포넌트 상세 - -### 1. CLI (bin/vync.js + src/cli/) - -기반 레이어. 터미널에서 직접 실행 가능하며, /vync 커맨드가 내부적으로 호출. - -**서브커맨드**: -- `vync init ` -- 빈 캔버스 .vync 파일 생성. 이미 존재하면 에러. -- `vync open ` -- 서버 시작 + 브라우저 열기. background process로 실행, PID를 /tmp/vync-server.pid에 저장. -- `vync stop` -- PID 파일 기반으로 서버 종료. - -**프로젝트 경로 해결**: -- `VYNC_HOME` 환경변수 우선 -- 없으면 cwd에서 상위로 탐색하여 Vync 프로젝트 package.json 탐색 -- 못 찾으면 에러 메시지 + 종료 - -**기존 server.ts 재사용**: -- `src/server/server.ts`의 `main()` 함수를 export하여 `open.ts`에서 import 호출 -- 브라우저 열기: `open` npm 패키지 (크로스 플랫폼) - -### 2. Skill: vync-editing - -핵심 지식 패키지. Claude Code가 .vync JSON을 올바르게 편집할 수 있도록 가이드. - -**SKILL.md 트리거 조건** (description 키워드): -- `.vync` 파일 편집/생성/수정 -- 마인드맵, 다이어그램, 플로우차트, 캔버스 생성 -- PlaitElement, Plait, Drawnix 관련 작업 - -**SKILL.md 본문** (~100단어 개요): -- .vync 파일 포맷 요약 (version, viewport, elements) -- ID 생성 규칙 (idCreator(5), 문자셋) -- 편집 워크플로우: 1) 대상 파일 확인 2) references/ 로드 3) 생성/수정 4) 검증 -- 주의사항: children 배열 비어있으면 안 됨, boundId 참조 정확성 - -**references/** (필요 시 로드): -- `mindmap.md`: MindElement 구조, 트리 계층, data.topic, rightNodeCount. 마인드맵 생성 템플릿 포함. -- `geometry.md`: PlaitGeometry 구조, shape enum 전체 목록, points 바운딩 박스 규칙. 도형 배치 템플릿 포함. -- `arrow-line.md`: PlaitArrowLine 구조, boundId + connection 좌표 매핑 테이블, 연결선 생성 주의사항. -- `coordinates.md`: 좌표계 규칙, Point 타입, 바운딩 박스, 레이아웃 패턴 (격자 배치, 트리 배치). - -**scripts/**: -- `validate.js`: stdin으로 파일 경로 받아 JSON Schema 검증. exit 0 = OK, exit 1 = 에러 (stderr에 상세). -- `generate-id.js`: idCreator(5) 구현. `node generate-id.js` -> 5자 랜덤 ID 출력. 테스트/디버깅용. - -**assets/**: -- `schema.json`: .vync JSON Schema. VyncFile 최상위 + elements oneOf (mindmap, geometry, arrow-line, vector-line, image). -- `mindmap.vync`: 3단계 마인드맵 예시 (루트 + 2레벨 자식). -- `flowchart.vync`: 도형 3개 + 연결선 2개 플로우차트 예시. - -### 3. Command: /vync - -CLI thin wrapper. Claude Code 안에서 서버 관리. - -```yaml ---- -description: Vync 서버 및 파일 관리 (init, open, stop, read) -allowed-tools: Bash(vync:*), Read -argument-hint: [file] ---- -``` - -**동작**: -- `$ARGUMENTS`를 `vync` CLI에 전달: `vync $ARGUMENTS` -- `read ` 서브커맨드만 특수 처리: Read 도구로 파일을 읽고 사람이 읽을 수 있는 형태로 요약 - -### 4. Command: /vync-create - -핵심 편집 진입점. Skill을 명시적으로 트리거하여 다이어그램 생성. - -```yaml ---- -description: .vync 다이어그램 생성 (마인드맵, 플로우차트, 자유 배치) -allowed-tools: Read, Write, Edit, Bash -argument-hint: ---- -``` - -**동작**: -1. vync-editing skill 참조 (명시적 안내) -2. 타입에 맞는 references/ 로드 안내 -3. 대상 .vync 파일 확인 (없으면 init 제안) -4. Claude가 PlaitElement[] JSON 생성 -5. Write로 파일에 저장 -6. PostToolUse hook이 자동 검증 - -### 5. Hooks - -**PostToolUse** (Edit|Write on *.vync): -```json -{ - "matcher": "Edit|Write", - "hooks": [{ - "type": "command", - "command": "jq -r '.tool_input.file_path // \"\"' | { read f; [[ \"$f\" == *.vync ]] && node ~/.claude/skills/vync-editing/scripts/validate.js \"$f\" || exit 0; }" - }] -} -``` -- .vync 확장자 사전 필터링 (~10ms 오버헤드) -- .vync 파일인 경우만 validate.js 실행 -- 검증 실패 시 stderr에 경고 출력 (Claude가 피드백으로 수신) - -**SessionEnd**: -```json -{ - "matcher": "*", - "hooks": [{ - "type": "command", - "command": "[ -f /tmp/vync-server.pid ] && kill $(cat /tmp/vync-server.pid) 2>/dev/null && rm /tmp/vync-server.pid; exit 0" - }] -} -``` -- 서버 PID 파일 존재 시 프로세스 종료 + PID 파일 삭제 - ---- - -## 설치/제거 - -### install.sh - -1. Skill 심볼릭 링크: `~/.claude/skills/vync-editing -> claude-plugin/skills/vync-editing` -2. Commands 심볼릭 링크: `~/.claude/commands/vync.md`, `~/.claude/commands/vync-create.md` -3. Settings 머지: `jq`로 기존 `~/.claude/settings.json`에 hooks 설정 안전하게 추가 -4. 환경변수: `VYNC_HOME` 설정 (settings.json의 env 섹션) -5. CLI 전역 등록: `npm link` 실행 - -### uninstall.sh - -1. 심볼릭 링크 제거 -2. Settings에서 vync hooks 제거 -3. VYNC_HOME 환경변수 제거 -4. `npm unlink` 실행 - ---- - -## 사용자 워크플로우 - -```bash -# 1회: 설치 -cd ~/projects/Vync && ./claude-plugin/install.sh - -# Claude Code 세션 -> /vync init plan.vync # 빈 캔버스 생성 -> /vync open plan.vync # 서버 시작 + 브라우저 열기 -> /vync-create mindmap "Sprint 1 계획: 인증, 결제, 대시보드" -# -> skill 로드 -> MindElement[] 생성 -> Write -> 자동 검증 -# -> 웹 UI에 즉시 마인드맵 반영 -> "에러 핸들링 브랜치 추가해줘" # 자연어 -> skill 자동 트리거 -> /vync stop # 서버 종료 -# (세션 종료 시 SessionEnd hook이 자동 정리) -``` - ---- - -## 기존 Phase 4 태스크 매핑 - -| 기존 태스크 | 새 위치 | -|------------|---------| -| 4.1 `vync init` | CLI (bin/vync.js + src/cli/init.ts) | -| 4.2 `vync open` | CLI (bin/vync.js + src/cli/open.ts) | -| 4.3 CLAUDE.md | Skill: vync-editing (SKILL.md + references/) | -| 4.4 .vync.schema.json | Skill: assets/schema.json + Hook: validate.js | -| 4.5 examples/*.vync | Skill: assets/mindmap.vync, flowchart.vync | - ---- - -## 리뷰에서 발견된 리스크와 대응 - -| 리스크 | 대응 | -|--------|------| -| 전역 Hook이 모든 세션에서 실행 | .vync 확장자 사전 필터링 (~10ms) | -| 전역 플러그인이 Vync 프로젝트에 의존 | VYNC_HOME 환경변수 + 프로젝트 탐색 fallback | -| CLAUDE.md 없이 Skill 트리거 불확실 | description 키워드 + /vync-create 명시적 진입점 | -| settings.json hooks 머지 충돌 | jq로 안전 머지 (기존 hook 보존) | -| 다른 프로젝트에서 오트리거 | Skill 본문에서 .vync 파일 존재 확인 분기 | -| npm link 깨짐 (프로젝트 이동) | VYNC_HOME fallback | - ---- - -## 향후 확장 (Phase 4 이후) - -- **Agent: vync-editor** -- 복잡한 10+ 노드 다이어그램 위임. Skill 안정화 후 추가. -- **MCP Server** -- D-010에서 MVP 제외. 구조화된 AI 조작 API. -- **배포 가능 플러그인** -- .claude-plugin/ manifest + marketplace 등록. diff --git a/docs/archive/2026-03-07-phase4-implementation.md b/docs/archive/2026-03-07-phase4-implementation.md deleted file mode 100644 index f102df2..0000000 --- a/docs/archive/2026-03-07-phase4-implementation.md +++ /dev/null @@ -1,1332 +0,0 @@ -# Phase 4: Claude Code 통합 플러그인 구현 계획 - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Claude Code의 전역 확장 시스템을 통해 .vync 파일의 전체 라이프사이클을 관리하는 통합 도구 구축. - -**Architecture:** CLI(bin/vync.js)가 기반 레이어로 서버 관리를 담당하고, Skill(vync-editing)이 지식 레이어로 .vync 편집을 가이드한다. Commands는 CLI의 thin wrapper + Skill 트리거. PostToolUse hook이 자동 검증. - -**Tech Stack:** Node.js 25, TypeScript, tsx, express, open (npm), chokidar, Ajv (JSON Schema validation) - -**Design Doc:** `docs/plans/2026-03-07-phase4-claude-plugin-design.md` - ---- - -## Task 1: CLI 기반 레이어 — init 명령어 - -`vync init ` 로 빈 .vync 캔버스 파일을 생성하는 CLI 명령어. - -**Files:** -- Create: `src/cli/init.ts` -- Test: `src/cli/__tests__/init.test.ts` - -**Step 1: Write the failing test** - -```typescript -// src/cli/__tests__/init.test.ts -import { describe, it, expect, afterEach } from 'vitest'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { vyncInit } from '../init.js'; - -describe('vyncInit', () => { - const tmpDir = path.join(os.tmpdir(), 'vync-test-init'); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - it('creates a valid .vync file with empty canvas', async () => { - await fs.mkdir(tmpDir, { recursive: true }); - const filePath = path.join(tmpDir, 'test.vync'); - - await vyncInit(filePath); - - const content = await fs.readFile(filePath, 'utf-8'); - const data = JSON.parse(content); - expect(data.version).toBe(1); - expect(data.viewport).toEqual({ zoom: 1, x: 0, y: 0 }); - expect(data.elements).toEqual([]); - }); - - it('throws if file already exists', async () => { - await fs.mkdir(tmpDir, { recursive: true }); - const filePath = path.join(tmpDir, 'existing.vync'); - await fs.writeFile(filePath, '{}'); - - await expect(vyncInit(filePath)).rejects.toThrow('already exists'); - }); - - it('appends .vync extension if missing', async () => { - await fs.mkdir(tmpDir, { recursive: true }); - const filePath = path.join(tmpDir, 'test'); - - await vyncInit(filePath); - - const exists = await fs.access(path.join(tmpDir, 'test.vync')).then(() => true).catch(() => false); - expect(exists).toBe(true); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run src/cli/__tests__/init.test.ts` -Expected: FAIL — module `../init.js` not found - -**Step 3: Write minimal implementation** - -```typescript -// src/cli/init.ts -import fs from 'node:fs/promises'; -import path from 'node:path'; -import type { VyncFile } from '../shared/types.js'; - -const EMPTY_CANVAS: VyncFile = { - version: 1, - viewport: { zoom: 1, x: 0, y: 0 }, - elements: [], -}; - -export async function vyncInit(filePath: string): Promise { - // Append .vync extension if missing - const resolved = filePath.endsWith('.vync') ? filePath : `${filePath}.vync`; - const absolute = path.resolve(resolved); - - // Check if file already exists - try { - await fs.access(absolute); - throw new Error(`File already exists: ${absolute}`); - } catch (err: any) { - if (err.code !== 'ENOENT') throw err; - } - - // Ensure parent directory exists - await fs.mkdir(path.dirname(absolute), { recursive: true }); - - // Write empty canvas - await fs.writeFile(absolute, JSON.stringify(EMPTY_CANVAS, null, 2), 'utf-8'); - - return absolute; -} -``` - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run src/cli/__tests__/init.test.ts` -Expected: 3 tests PASS - -**Step 5: Commit** - -```bash -git add src/cli/init.ts src/cli/__tests__/init.test.ts -git commit -m "feat(cli): add vync init command - -Creates empty .vync canvas file with version 1 format. -Auto-appends .vync extension, rejects existing files." -``` - ---- - -## Task 2: CLI 기반 레이어 — open/stop 명령어 + bin 진입점 - -`vync open ` 로 서버를 background로 시작하고 브라우저를 열고, `vync stop` 으로 종료. - -**Files:** -- Modify: `src/server/server.ts` (main 함수 export) -- Create: `src/cli/open.ts` -- Create: `bin/vync.js` -- Modify: `package.json` (bin 필드 + open 의존성) - -**Step 1: Install `open` package** - -Run: `npm install open` - -**Step 2: Refactor server.ts — export startServer function** - -현재 `src/server/server.ts:12-143`의 `main()` 함수를 `startServer(filePath, options?)` 로 리팩토링하여 외부에서 호출 가능하게 만든다. - -```typescript -// src/server/server.ts — 변경 사항 (기존 main을 startServer로 분리) -// 기존 main() 맨 위의 filePath 파싱 로직을 파라미터로 이동 -// export async function startServer(filePath: string, options?: { openBrowser?: boolean }) -// 기존 process.argv[2] 파싱은 파일 하단의 CLI 실행 부분으로 이동 -``` - -수정할 부분: -- `async function main()` → `export async function startServer(resolvedPath: string, options: { openBrowser?: boolean } = {})` -- `const filePath = process.argv[2];` 제거 (파라미터로 받음) -- `const resolvedPath = path.resolve(filePath);` 제거 (이미 resolved) -- server.listen 콜백에서 `options.openBrowser`이면 `open(url)` 호출 -- 파일 하단에 직접 실행 시 기존 동작 유지: `if (process.argv[1] === fileURLToPath(import.meta.url)) { ... }` -- `startServer`가 shutdown 함수를 반환하여 외부에서 종료 가능 - -```typescript -// src/server/server.ts 리팩토링된 전체 구조 -import http from 'node:http'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import express from 'express'; -import { createServer as createViteServer } from 'vite'; -import { createFileWatcher } from './file-watcher.js'; -import { createWsServer } from './ws-handler.js'; -import { createSyncService } from './sync-service.js'; -import type { VyncFile } from '../shared/types.js'; - -const PORT = 3100; - -export async function startServer(resolvedPath: string, options: { openBrowser?: boolean } = {}) { - const sync = createSyncService(resolvedPath); - try { - await sync.init(); - } catch (err: any) { - if (err.code === 'ENOENT') { - console.error(`[vync] File not found: ${resolvedPath}`); - } else { - console.error(`[vync] Invalid JSON in file: ${resolvedPath}`); - } - process.exit(1); - } - - const app = express(); - app.use(express.json({ limit: '10mb' })); - - // --- CORS (localhost only) --- - const allowedOrigins = [ - `http://localhost:${PORT}`, - `http://127.0.0.1:${PORT}`, - ]; - - app.use((_req, res, next) => { - const origin = _req.headers.origin; - if (origin && allowedOrigins.includes(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Access-Control-Allow-Methods', 'GET, PUT'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - } - if (_req.method === 'OPTIONS') { - res.sendStatus(204); - return; - } - next(); - }); - - // --- API Routes --- - app.get('/api/sync', async (_req, res) => { - try { - const data = await sync.readFile(); - res.json(data); - } catch (err) { - console.error('[vync] Error reading file:', err); - res.status(500).json({ error: 'Failed to read file' }); - } - }); - - app.put('/api/sync', async (req, res) => { - try { - const data = req.body as VyncFile; - if (!data || !Array.isArray(data.elements)) { - res.status(400).json({ error: 'Invalid VyncFile format' }); - return; - } - await sync.writeFile(data); - res.json({ ok: true }); - } catch (err) { - console.error('[vync] Error writing file:', err); - res.status(500).json({ error: 'Failed to write file' }); - } - }); - - const server = http.createServer(app); - - // --- Vite dev server --- - const projectRoot = process.env.VYNC_HOME || process.cwd(); - const webAppRoot = path.resolve(projectRoot, 'apps/web'); - - const vite = await createViteServer({ - configFile: path.resolve(webAppRoot, 'vite.config.ts'), - root: webAppRoot, - server: { - middlewareMode: true, - hmr: { server }, - }, - }); - - app.use(vite.middlewares); - - // --- WebSocket --- - const ws = createWsServer(server, PORT); - - // --- File watcher --- - const watcher = createFileWatcher(resolvedPath, (content) => { - const data = sync.handleFileChange(content); - if (data) { - ws.broadcast({ type: 'file-changed', data }); - console.log('[vync] File changed externally, notified clients'); - } - }); - - // --- Shutdown function --- - const shutdown = async () => { - console.log('\n[vync] Shutting down...'); - await watcher.close(); - ws.close(); - await vite.close(); - server.close(); - }; - - process.on('SIGINT', async () => { await shutdown(); process.exit(0); }); - process.on('SIGTERM', async () => { await shutdown(); process.exit(0); }); - - const url = `http://localhost:${PORT}`; - - await new Promise((resolve) => { - server.listen(PORT, '127.0.0.1', () => { - console.log(`[vync] Server running at ${url}`); - console.log(`[vync] Watching: ${resolvedPath}`); - console.log(`[vync] WebSocket: ws://localhost:${PORT}/ws`); - resolve(); - }); - }); - - if (options.openBrowser) { - const openModule = await import('open'); - await openModule.default(url); - } - - return { shutdown, server, url }; -} - -// Direct execution (backward compat with `tsx src/server/server.ts `) -const currentFile = fileURLToPath(import.meta.url); -if (process.argv[1] === currentFile || process.argv[1]?.endsWith('/server.ts')) { - const filePath = process.argv[2]; - if (!filePath) { - console.error('Usage: npx tsx src/server/server.ts '); - process.exit(1); - } - startServer(path.resolve(filePath)); -} -``` - -**Step 3: Create src/cli/open.ts** - -```typescript -// src/cli/open.ts -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const PID_FILE = '/tmp/vync-server.pid'; - -export async function vyncOpen(filePath: string): Promise { - const resolved = path.resolve(filePath); - - // Verify file exists - try { - await fs.access(resolved); - } catch { - console.error(`[vync] File not found: ${resolved}`); - console.error('[vync] Run "vync init " first.'); - process.exit(1); - } - - // Check if server already running - try { - const existingPid = await fs.readFile(PID_FILE, 'utf-8'); - process.kill(Number(existingPid), 0); // Check if process exists - console.error(`[vync] Server already running (PID ${existingPid.trim()}). Run "vync stop" first.`); - process.exit(1); - } catch { - // Not running, continue - } - - // Write PID file - await fs.writeFile(PID_FILE, String(process.pid), 'utf-8'); - - // Start server (dynamic import to avoid loading vite at CLI parse time) - const { startServer } = await import('../server/server.js'); - await startServer(resolved, { openBrowser: true }); -} - -export async function vyncStop(): Promise { - try { - const pid = await fs.readFile(PID_FILE, 'utf-8'); - process.kill(Number(pid), 'SIGTERM'); - await fs.unlink(PID_FILE); - console.log(`[vync] Server stopped (PID ${pid.trim()})`); - } catch { - console.error('[vync] No running server found.'); - } -} -``` - -**Step 4: Create bin/vync.js** - -```javascript -#!/usr/bin/env node - -// bin/vync.js — CLI entry point -// Delegates to src/cli/ via tsx - -import { execSync, spawn } from 'node:child_process'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = process.env.VYNC_HOME || path.resolve(__dirname, '..'); - -const [,, command, ...args] = process.argv; - -const USAGE = `Usage: vync [options] - -Commands: - init Create an empty .vync canvas file - open Start server and open browser - stop Stop the running server - -Examples: - vync init plan.vync - vync open plan.vync - vync stop`; - -if (!command || command === '--help' || command === '-h') { - console.log(USAGE); - process.exit(0); -} - -// Run the TypeScript CLI module via tsx -const cliModule = { - init: path.join(projectRoot, 'src/cli/init.ts'), - open: path.join(projectRoot, 'src/cli/open.ts'), -}[command]; - -if (!cliModule) { - console.error(`Unknown command: ${command}\n`); - console.log(USAGE); - process.exit(1); -} - -// Use tsx to run TypeScript directly -const tsxArgs = ['--import', 'tsx/esm', cliModule, command, ...args]; -const child = spawn('node', tsxArgs, { - cwd: projectRoot, - stdio: 'inherit', - env: { ...process.env, VYNC_HOME: projectRoot }, -}); - -child.on('exit', (code) => process.exit(code ?? 0)); -``` - -Wait, this approach is complex. Simpler: bin/vync.js uses tsx to run a single entry module. - -Actually, let me reconsider. The simplest approach: - -```javascript -#!/usr/bin/env node -// bin/vync.js -// Uses tsx register hook to run TypeScript src/cli/ modules directly -``` - -Let me rethink. The cleanest approach for bin/vync.js: - -Option A: bin/vync.js is a thin JS script that spawns `tsx src/cli/main.ts ` -Option B: bin/vync.js is itself TypeScript run via tsx shebang -Option C: bin/vync.js is JavaScript that imports compiled output - -For MVP, option A is simplest and most reliable. - -Let me rewrite the plan to use a single `src/cli/main.ts` entry that handles all subcommands. - -**Step 5: Update package.json** - -Add to `package.json`: -- `"bin": { "vync": "./bin/vync.js" }` -- `"type": "module"` (if not already) -- `open` dependency already installed in step 1 - -**Step 6: Verify CLI works** - -Run: `node bin/vync.js --help` -Expected: Usage text printed - -Run: `node bin/vync.js init /tmp/test-cli.vync` -Expected: File created, success message - -Run: `cat /tmp/test-cli.vync` -Expected: `{"version":1,"viewport":{"zoom":1,"x":0,"y":0},"elements":[]}` - -**Step 7: Commit** - -```bash -git add src/server/server.ts src/cli/open.ts bin/vync.js package.json -git commit -m "feat(cli): add vync open/stop commands and bin entry point - -- Refactor server.ts to export startServer() for reuse -- Add open.ts with PID-based server management -- Add bin/vync.js entry point routing init/open/stop -- Add 'open' npm package for cross-platform browser launch" -``` - ---- - -## Task 3: JSON Schema + 검증 스크립트 - -`validate.js`가 hook에서 호출되어 .vync 파일의 JSON Schema 유효성을 검증. - -**Files:** -- Create: `claude-plugin/skills/vync-editing/assets/schema.json` -- Create: `claude-plugin/skills/vync-editing/scripts/validate.js` -- Create: `claude-plugin/skills/vync-editing/scripts/generate-id.js` -- Copy: `.vync.schema.json` (프로젝트 루트 복사본) - -**Step 1: Create JSON Schema** - -```json -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "VyncFile", - "description": ".vync canvas file format", - "type": "object", - "required": ["version", "viewport", "elements"], - "properties": { - "version": { - "type": "integer", - "const": 1 - }, - "viewport": { - "$ref": "#/$defs/Viewport" - }, - "elements": { - "type": "array", - "items": { - "$ref": "#/$defs/PlaitElement" - } - } - }, - "additionalProperties": false, - "$defs": { - "Viewport": { - "type": "object", - "required": ["zoom", "x", "y"], - "properties": { - "zoom": { "type": "number", "exclusiveMinimum": 0 }, - "x": { "type": "number" }, - "y": { "type": "number" } - }, - "additionalProperties": false - }, - "Point": { - "type": "array", - "items": { "type": "number" }, - "minItems": 2, - "maxItems": 2 - }, - "SlateText": { - "type": "object", - "required": ["children"], - "properties": { - "children": { - "type": "array", - "minItems": 1 - }, - "align": { "type": "string" } - } - }, - "PlaitElement": { - "type": "object", - "required": ["id"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "type": { "type": "string" }, - "children": { "type": "array" }, - "points": { - "type": "array", - "items": { "$ref": "#/$defs/Point" } - }, - "groupId": { "type": "string" }, - "angle": { "type": "number" } - } - } - } -} -``` - -Note: Schema는 유연하게 작성. PlaitElement는 `[key: string]: any` 인터페이스이므로 `additionalProperties: true` (기본값). 필수 필드(id)만 강제하고, 타입별 세부 검증은 하지 않음 (AI가 잘못 생성해도 웹 UI에서 무시되지 크래시하지 않음). - -**Step 2: Create validate.js** - -```javascript -#!/usr/bin/env node -// claude-plugin/skills/vync-editing/scripts/validate.js -// Usage: node validate.js -// Exit 0 = valid, Exit 1 = invalid (errors on stderr) - -import { readFileSync } from 'node:fs'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const schemaPath = resolve(__dirname, '..', 'assets', 'schema.json'); - -const filePath = process.argv[2]; -if (!filePath) { - console.error('Usage: node validate.js '); - process.exit(1); -} - -try { - const content = readFileSync(resolve(filePath), 'utf-8'); - const data = JSON.parse(content); - const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); - - // Basic structural validation (no Ajv dependency — keep it lightweight) - const errors = []; - - if (typeof data.version !== 'number') errors.push('missing or invalid "version" field'); - if (!data.viewport || typeof data.viewport !== 'object') errors.push('missing "viewport" object'); - else { - if (typeof data.viewport.zoom !== 'number') errors.push('viewport.zoom must be a number'); - if (typeof data.viewport.x !== 'number') errors.push('viewport.x must be a number'); - if (typeof data.viewport.y !== 'number') errors.push('viewport.y must be a number'); - } - if (!Array.isArray(data.elements)) errors.push('"elements" must be an array'); - else { - data.elements.forEach((el, i) => { - if (!el.id || typeof el.id !== 'string') errors.push(`elements[${i}]: missing or invalid "id"`); - if (el.id && el.id.length < 1) errors.push(`elements[${i}]: "id" must not be empty`); - }); - - // Check for duplicate IDs (recursive) - const ids = new Set(); - function collectIds(elements) { - for (const el of elements) { - if (el.id) { - if (ids.has(el.id)) errors.push(`duplicate id: "${el.id}"`); - ids.add(el.id); - } - if (Array.isArray(el.children)) collectIds(el.children); - } - } - collectIds(data.elements); - } - - if (errors.length > 0) { - console.error(`[vync-validate] ${filePath}: ${errors.length} error(s)`); - errors.forEach(e => console.error(` - ${e}`)); - process.exit(1); - } - - console.log(`[vync-validate] ${filePath}: OK`); - process.exit(0); -} catch (err) { - if (err instanceof SyntaxError) { - console.error(`[vync-validate] ${filePath}: Invalid JSON — ${err.message}`); - } else { - console.error(`[vync-validate] ${filePath}: ${err.message}`); - } - process.exit(1); -} -``` - -Note: 의존성 0 (Ajv 불필요). 기본 구조 검증 + 중복 ID 검사. 전역 스크립트이므로 node_modules 없이 실행 가능해야 함. - -**Step 3: Create generate-id.js** - -```javascript -#!/usr/bin/env node -// claude-plugin/skills/vync-editing/scripts/generate-id.js -// Usage: node generate-id.js [count] -// Generates idCreator(5) compatible IDs - -const CHARS = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz'; -const LENGTH = 5; - -function generateId() { - let id = ''; - for (let i = 0; i < LENGTH; i++) { - id += CHARS[Math.floor(Math.random() * CHARS.length)]; - } - return id; -} - -const count = parseInt(process.argv[2] || '1', 10); -for (let i = 0; i < count; i++) { - console.log(generateId()); -} -``` - -**Step 4: Copy schema to project root** - -Run: `cp claude-plugin/skills/vync-editing/assets/schema.json .vync.schema.json` - -**Step 5: Test validate.js** - -Run: `echo '{"version":1,"viewport":{"zoom":1,"x":0,"y":0},"elements":[]}' > /tmp/valid.vync && node claude-plugin/skills/vync-editing/scripts/validate.js /tmp/valid.vync` -Expected: `[vync-validate] /tmp/valid.vync: OK` - -Run: `echo '{"bad":true}' > /tmp/invalid.vync && node claude-plugin/skills/vync-editing/scripts/validate.js /tmp/invalid.vync; echo "exit: $?"` -Expected: errors printed, `exit: 1` - -**Step 6: Test generate-id.js** - -Run: `node claude-plugin/skills/vync-editing/scripts/generate-id.js 3` -Expected: 3 lines of 5-character IDs - -**Step 7: Commit** - -```bash -git add claude-plugin/skills/vync-editing/assets/schema.json \ - claude-plugin/skills/vync-editing/scripts/validate.js \ - claude-plugin/skills/vync-editing/scripts/generate-id.js \ - .vync.schema.json -git commit -m "feat(plugin): add JSON Schema and validation scripts - -- schema.json: VyncFile structure validation -- validate.js: zero-dependency structural validator + duplicate ID check -- generate-id.js: idCreator(5) compatible ID generator" -``` - ---- - -## Task 4: Skill — SKILL.md + references - -핵심 지식 패키지. Claude Code가 .vync 편집 시 자동 로드되는 가이드. - -**Files:** -- Create: `claude-plugin/skills/vync-editing/SKILL.md` -- Create: `claude-plugin/skills/vync-editing/references/mindmap.md` -- Create: `claude-plugin/skills/vync-editing/references/geometry.md` -- Create: `claude-plugin/skills/vync-editing/references/arrow-line.md` -- Create: `claude-plugin/skills/vync-editing/references/coordinates.md` - -**Step 1: Create SKILL.md** - -```markdown ---- -name: vync-editing -description: Edit .vync canvas files (PlaitElement JSON). Use when creating or modifying mindmaps, flowcharts, diagrams in .vync format. Triggers on .vync file editing, mindmap/diagram creation, PlaitElement manipulation, Plait/Drawnix canvas operations. ---- - -# Vync Canvas Editing - -## .vync File Format - -```json -{ - "version": 1, - "viewport": { "zoom": 1, "x": 0, "y": 0 }, - "elements": [ /* PlaitElement[] */ ] -} -``` - -## ID Generation Rule - -`idCreator(5)` — 5-char random string from: `ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz` -All IDs must be unique across the entire file (including nested children). -Generate with: `node ~/.claude/skills/vync-editing/scripts/generate-id.js` - -## Editing Workflow - -1. **Read** the target .vync file first -2. **Load** the relevant reference for your element type: - - Mindmap: `references/mindmap.md` - - Geometry (shapes): `references/geometry.md` - - Arrow lines: `references/arrow-line.md` - - Coordinate system: `references/coordinates.md` -3. **Generate** valid PlaitElement[] JSON following the reference -4. **Write** to file — PostToolUse hook will auto-validate - -## Critical Rules - -- `children` arrays in Slate text nodes must never be empty — minimum: `[{ "text": "" }]` -- Mindmap child nodes do NOT need `points` — layout engine auto-places them -- Bounding box points: `[[x1,y1], [x2,y2]]` where x1 < x2, y1 < y2 -- When modifying existing files, preserve all fields you don't intend to change -- Do NOT modify `viewport` unless explicitly asked - -## Element Types - -| Type | Difficulty | Primary Use | -|------|-----------|-------------| -| `mindmap` / `mind_child` | Easy | Planning, brainstorming | -| `geometry` | Easy | Flowcharts, diagrams | -| `arrow-line` | Medium-Hard | Connecting shapes (boundId binding) | -| `vector-line` | Easy | Free-form lines | -| `image` | Hard (avoid) | Use web UI instead | - -## Quick Templates - -### Minimal Mindmap (most common) -```json -{ - "id": "<5-char>", "type": "mindmap", - "data": { "topic": { "children": [{ "text": "Root Topic" }] } }, - "children": [ - { - "id": "<5-char>", "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Child 1" }] } }, - "children": [] - } - ], - "width": 100, "height": 50, "points": [[0, 0]], "isRoot": true -} -``` - -### Minimal Rectangle -```json -{ - "id": "<5-char>", "type": "geometry", "shape": "rectangle", - "points": [[0, 0], [200, 80]], - "text": { "children": [{ "text": "Label" }] }, - "children": [] -} -``` -``` - -**Step 2: Create references/mindmap.md** - -Content based on ARCHITECTURE.md §4.2 (PlaitMind) + §7.5 (마인드맵 난이도 평가). -Include: MindElement interface, data.topic structure, children tree, rightNodeCount, styling fields, complete 3-level mindmap example. - -**Step 3: Create references/geometry.md** - -Content based on ARCHITECTURE.md §4.2 (PlaitGeometry) + §7.6. -Include: shape enum full list (BasicShapes + FlowchartSymbols), points bounding box, text ParagraphElement, autoSize, complete flowchart example with 3 shapes. - -**Step 4: Create references/arrow-line.md** - -Content based on ARCHITECTURE.md §4.2 (PlaitArrowLine) + §7.7. -Include: source/target handles, boundId binding, connection coordinate mapping table (`[0.5,0]`=top center, `[1,0.5]`=right center, etc.), marker types, texts, complete example of 2 shapes + 1 arrow connecting them. - -**Step 5: Create references/coordinates.md** - -Content based on ARCHITECTURE.md §4.3. -Include: Point type, bounding box rules, mindmap root positioning, grid layout strategy for placing multiple shapes without overlap, viewport coordinates. - -**Step 6: Commit** - -```bash -git add claude-plugin/skills/vync-editing/SKILL.md \ - claude-plugin/skills/vync-editing/references/ -git commit -m "feat(plugin): add vync-editing skill with references - -- SKILL.md: trigger metadata, overview, editing workflow, quick templates -- references/: detailed guides for mindmap, geometry, arrow-line, coordinates" -``` - ---- - -## Task 5: Skill Assets — 예시 .vync 파일 - -Claude가 참조할 수 있는 실제 .vync 파일 예시. - -**Files:** -- Create: `claude-plugin/skills/vync-editing/assets/mindmap.vync` -- Create: `claude-plugin/skills/vync-editing/assets/flowchart.vync` -- Copy: `examples/mindmap.vync`, `examples/flowchart.vync` - -**Step 1: Create mindmap.vync example** - -3단계 마인드맵: "프로젝트 계획" 루트 → 2개 브랜치("설계", "구현") → 각 2개 리프. -모든 ID는 generate-id.js로 생성. data.topic은 Slate 텍스트 노드 형식. - -```json -{ - "version": 1, - "viewport": { "zoom": 1, "x": 0, "y": 0 }, - "elements": [ - { - "id": "AbCdE", - "type": "mindmap", - "data": { "topic": { "children": [{ "text": "Project Plan" }] } }, - "children": [ - { - "id": "FgHjK", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Design" }] } }, - "children": [ - { - "id": "MnPqR", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Architecture" }] } }, - "children": [] - }, - { - "id": "StWxY", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Data Model" }] } }, - "children": [] - } - ] - }, - { - "id": "aBcDe", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Implementation" }] } }, - "children": [ - { - "id": "fGhJk", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Backend" }] } }, - "children": [] - }, - { - "id": "mNpQr", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Frontend" }] } }, - "children": [] - } - ] - } - ], - "width": 100, - "height": 50, - "points": [[0, 0]], - "isRoot": true - } - ] -} -``` - -**Step 2: Create flowchart.vync example** - -도형 3개(Start → Process → End) + 연결선 2개. - -```json -{ - "version": 1, - "viewport": { "zoom": 1, "x": 0, "y": 0 }, - "elements": [ - { - "id": "sT1aB", - "type": "geometry", - "shape": "process", - "points": [[0, 0], [160, 60]], - "text": { "children": [{ "text": "Start" }], "align": "center" }, - "children": [] - }, - { - "id": "pR2cD", - "type": "geometry", - "shape": "process", - "points": [[0, 120], [160, 180]], - "text": { "children": [{ "text": "Process Data" }], "align": "center" }, - "children": [] - }, - { - "id": "eN3eF", - "type": "geometry", - "shape": "process", - "points": [[0, 240], [160, 300]], - "text": { "children": [{ "text": "End" }], "align": "center" }, - "children": [] - }, - { - "id": "aR4gH", - "type": "arrow-line", - "shape": "elbow", - "source": { - "marker": "none", - "boundId": "sT1aB", - "connection": [0.5, 1] - }, - "target": { - "marker": "arrow", - "boundId": "pR2cD", - "connection": [0.5, 0] - }, - "points": [[80, 60], [80, 120]], - "texts": [], - "opacity": 1, - "children": [] - }, - { - "id": "aR5jK", - "type": "arrow-line", - "shape": "elbow", - "source": { - "marker": "none", - "boundId": "pR2cD", - "connection": [0.5, 1] - }, - "target": { - "marker": "arrow", - "boundId": "eN3eF", - "connection": [0.5, 0] - }, - "points": [[80, 180], [80, 240]], - "texts": [], - "opacity": 1, - "children": [] - } - ] -} -``` - -**Step 3: Copy to examples/** - -```bash -mkdir -p examples -cp claude-plugin/skills/vync-editing/assets/mindmap.vync examples/ -cp claude-plugin/skills/vync-editing/assets/flowchart.vync examples/ -``` - -**Step 4: Validate examples** - -Run: `node claude-plugin/skills/vync-editing/scripts/validate.js examples/mindmap.vync` -Expected: OK - -Run: `node claude-plugin/skills/vync-editing/scripts/validate.js examples/flowchart.vync` -Expected: OK - -**Step 5: Commit** - -```bash -git add claude-plugin/skills/vync-editing/assets/mindmap.vync \ - claude-plugin/skills/vync-editing/assets/flowchart.vync \ - examples/ -git commit -m "feat(plugin): add example .vync files - -- mindmap.vync: 3-level mind map (root + 2 branches + 4 leaves) -- flowchart.vync: 3 shapes + 2 arrow connections" -``` - ---- - -## Task 6: Slash Commands - -`/vync` (유틸리티 CLI wrapper)와 `/vync-create` (핵심 편집 진입점). - -**Files:** -- Create: `claude-plugin/commands/vync.md` -- Create: `claude-plugin/commands/vync-create.md` - -**Step 1: Create /vync command** - -```markdown ---- -description: Vync server and file management (init, open, stop, read) -allowed-tools: Bash(vync:*), Read -argument-hint: [file] ---- - -Run the Vync CLI command: `vync $ARGUMENTS` - -## Subcommands - -- `init ` — Create an empty .vync canvas file. Appends .vync extension if missing. -- `open ` — Start the Vync server (port 3100) and open browser. Server runs in foreground. -- `stop` — Stop the running Vync server. -- `read ` — Read a .vync file. Use the Read tool to read the file, then summarize the canvas contents in human-readable form: list all elements with their types, text content, and hierarchy. - -For `open`, the server will keep running in this terminal. The .vync file will be watched for changes and auto-synced to the web UI. - -For `read`, after reading the file with the Read tool, present a structured summary: -- Total element count -- Element tree (for mindmaps: indented hierarchy with topic text) -- For geometries: shape type + label + position -- For arrow-lines: source → target connections -``` - -**Step 2: Create /vync-create command** - -```markdown ---- -description: Create diagrams in .vync format (mindmap, flowchart, diagram) -allowed-tools: Read, Write, Edit, Bash(node:*) -argument-hint: ---- - -Create a .vync diagram based on the user's description. - -**You MUST use the vync-editing skill.** Load it now and follow its editing workflow. - -## Instructions - -1. **Parse arguments**: `$ARGUMENTS` contains ` `. - - Type: `mindmap`, `flowchart`, or `diagram` (free-form). - - Description: natural language description of what to create. - -2. **Find or create target file**: Look for .vync files in the current directory. If none exist, ask the user for a filename, then run `vync init ` first. - -3. **Load the appropriate reference** from the vync-editing skill: - - mindmap → `references/mindmap.md` - - flowchart → `references/geometry.md` + `references/arrow-line.md` - - diagram → `references/coordinates.md` + relevant type references - -4. **Generate IDs**: Use `node ~/.claude/skills/vync-editing/scripts/generate-id.js ` to generate unique 5-char IDs for all elements. - -5. **Create the elements** following the skill's templates and references exactly. - -6. **Write the .vync file** using the Write tool. If the file already has elements, Read it first and merge your new elements into the existing elements array. - -7. **Validation** will run automatically via the PostToolUse hook. If errors are reported, fix them. -``` - -**Step 3: Commit** - -```bash -git add claude-plugin/commands/ -git commit -m "feat(plugin): add /vync and /vync-create slash commands - -- /vync: CLI wrapper for init, open, stop, read -- /vync-create: diagram creation with skill-guided workflow" -``` - ---- - -## Task 7: Hooks 설정 + install/uninstall 스크립트 - -PostToolUse 자동 검증과 SessionEnd 서버 정리 hook. install.sh로 ~/.claude/에 설치. - -**Files:** -- Create: `claude-plugin/hooks.json` -- Create: `claude-plugin/install.sh` -- Create: `claude-plugin/uninstall.sh` - -**Step 1: Create hooks.json** - -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "jq -r '.tool_input.file_path // \"\"' | { read f; [[ \"$f\" == *.vync ]] && node \"$HOME/.claude/skills/vync-editing/scripts/validate.js\" \"$f\" 2>&1 || true; }" - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "[ -f /tmp/vync-server.pid ] && kill $(cat /tmp/vync-server.pid) 2>/dev/null && rm -f /tmp/vync-server.pid; exit 0" - } - ] - } - ] - } -} -``` - -**Step 2: Create install.sh** - -```bash -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -CLAUDE_DIR="$HOME/.claude" - -echo "[vync] Installing Claude Code plugin..." - -# 1. Skills -mkdir -p "$CLAUDE_DIR/skills" -if [ -L "$CLAUDE_DIR/skills/vync-editing" ]; then - rm "$CLAUDE_DIR/skills/vync-editing" -fi -ln -s "$SCRIPT_DIR/skills/vync-editing" "$CLAUDE_DIR/skills/vync-editing" -echo " [ok] Skill: vync-editing" - -# 2. Commands -mkdir -p "$CLAUDE_DIR/commands" -for cmd in vync.md vync-create.md; do - target="$CLAUDE_DIR/commands/$cmd" - [ -L "$target" ] && rm "$target" - ln -s "$SCRIPT_DIR/commands/$cmd" "$target" - echo " [ok] Command: /${cmd%.md}" -done - -# 3. Hooks — merge into settings.json -SETTINGS="$CLAUDE_DIR/settings.json" -if [ ! -f "$SETTINGS" ]; then - echo '{}' > "$SETTINGS" -fi - -# Use node to safely merge hooks (jq may not be available everywhere) -node -e " -const fs = require('fs'); -const settings = JSON.parse(fs.readFileSync('$SETTINGS', 'utf-8')); -const hooks = JSON.parse(fs.readFileSync('$SCRIPT_DIR/hooks.json', 'utf-8')); - -// Merge hooks: append vync hooks to existing arrays -if (!settings.hooks) settings.hooks = {}; -for (const [event, entries] of Object.entries(hooks.hooks)) { - if (!settings.hooks[event]) settings.hooks[event] = []; - // Remove existing vync hooks first (idempotent) - settings.hooks[event] = settings.hooks[event].filter( - e => !JSON.stringify(e).includes('vync') - ); - settings.hooks[event].push(...entries); -} - -// Set VYNC_HOME env -if (!settings.env) settings.env = {}; -settings.env.VYNC_HOME = '$PROJECT_ROOT'; - -fs.writeFileSync('$SETTINGS', JSON.stringify(settings, null, 2)); -" -echo " [ok] Hooks: PostToolUse, SessionEnd" -echo " [ok] Env: VYNC_HOME=$PROJECT_ROOT" - -# 4. npm link (global CLI) -cd "$PROJECT_ROOT" -npm link 2>/dev/null || echo " [warn] npm link failed — run manually if needed" -echo " [ok] CLI: vync (global)" - -echo "" -echo "[vync] Installation complete!" -echo " Restart Claude Code to activate." -``` - -**Step 3: Create uninstall.sh** - -```bash -#!/usr/bin/env bash -set -euo pipefail - -CLAUDE_DIR="$HOME/.claude" - -echo "[vync] Uninstalling Claude Code plugin..." - -# 1. Remove symlinks -rm -f "$CLAUDE_DIR/skills/vync-editing" -rm -f "$CLAUDE_DIR/commands/vync.md" -rm -f "$CLAUDE_DIR/commands/vync-create.md" -echo " [ok] Removed skills and commands" - -# 2. Remove hooks from settings.json -SETTINGS="$CLAUDE_DIR/settings.json" -if [ -f "$SETTINGS" ]; then - node -e " - const fs = require('fs'); - const settings = JSON.parse(fs.readFileSync('$SETTINGS', 'utf-8')); - if (settings.hooks) { - for (const event of Object.keys(settings.hooks)) { - settings.hooks[event] = settings.hooks[event].filter( - e => !JSON.stringify(e).includes('vync') - ); - if (settings.hooks[event].length === 0) delete settings.hooks[event]; - } - if (Object.keys(settings.hooks).length === 0) delete settings.hooks; - } - if (settings.env) delete settings.env.VYNC_HOME; - fs.writeFileSync('$SETTINGS', JSON.stringify(settings, null, 2)); - " - echo " [ok] Removed hooks and VYNC_HOME" -fi - -# 3. npm unlink -npm unlink -g vync 2>/dev/null || true -echo " [ok] Removed global CLI" - -echo "" -echo "[vync] Uninstallation complete." -``` - -**Step 4: Make scripts executable** - -```bash -chmod +x claude-plugin/install.sh claude-plugin/uninstall.sh -chmod +x claude-plugin/skills/vync-editing/scripts/validate.js -chmod +x claude-plugin/skills/vync-editing/scripts/generate-id.js -``` - -**Step 5: Test install (dry run)** - -Run: `bash claude-plugin/install.sh` -Expected: All `[ok]` messages, no errors. - -Verify: `ls -la ~/.claude/skills/vync-editing` → symlink to project -Verify: `ls -la ~/.claude/commands/vync.md` → symlink to project -Verify: `cat ~/.claude/settings.json | grep VYNC_HOME` → project path present -Verify: `cat ~/.claude/settings.json | grep PostToolUse` → hook present - -**Step 6: Test uninstall** - -Run: `bash claude-plugin/uninstall.sh` -Expected: All `[ok]` messages. -Verify: `ls ~/.claude/skills/vync-editing 2>/dev/null; echo $?` → 2 (not found) - -**Step 7: Re-install for actual use** - -Run: `bash claude-plugin/install.sh` - -**Step 8: Commit** - -```bash -git add claude-plugin/hooks.json claude-plugin/install.sh claude-plugin/uninstall.sh -git commit -m "feat(plugin): add hooks config and install/uninstall scripts - -- PostToolUse hook: auto-validate .vync files on Edit/Write -- SessionEnd hook: cleanup server PID on session end -- install.sh: symlink skills/commands, merge hooks, set VYNC_HOME -- uninstall.sh: clean removal of all plugin components" -``` - ---- - -## Task 8: PLAN.md 업데이트 + 문서 동기화 - -Phase 4 완료 상태 반영. - -**Files:** -- Modify: `docs/PLAN.md` - -**Step 1: Update PLAN.md Phase 4 checklist** - -Mark all Phase 4 tasks as complete. Update 태스크 설명 to reflect new structure: - -```markdown -## Phase 4: CLI 도구 + Claude Code 통합 플러그인 - -**목표**: CLI로 파일 관리, Claude Code 플러그인으로 .vync 편집의 전체 라이프사이클 관리. -**의존**: Phase 3 완료 - -- [x] 4.1 `vync init ` — 빈 캔버스 .vync 파일 생성 -- [x] 4.2 `vync open ` — 서버 시작 + 브라우저 열기 + PID 관리 -- [x] 4.3 Claude Code Skill (vync-editing) — .vync 편집 가이드 + references -- [x] 4.4 .vync.schema.json + validate.js — JSON Schema + 자동 검증 -- [x] 4.5 examples/*.vync — 마인드맵, 플로우차트 예시 -- [x] 4.6 Slash Commands — /vync (CLI wrapper), /vync-create (편집 진입점) -- [x] 4.7 Hooks — PostToolUse 자동 검증 + SessionEnd 서버 정리 -- [x] 4.8 install.sh / uninstall.sh — 전역 설치/제거 -``` - -**Step 2: Update 현재 상태** - -```markdown -**Phase**: 4 완료 → Phase 5 (E2E 검증) 진행 예정 -``` - -**Step 3: Commit** - -```bash -git add docs/PLAN.md -git commit -m "docs: update PLAN.md for Phase 4 completion" -``` - ---- - -## Summary - -| Task | Description | Files | Estimated Steps | -|------|-------------|-------|----------------| -| 1 | CLI init command | 2 files | 5 steps | -| 2 | CLI open/stop + bin entry | 4 files | 7 steps | -| 3 | JSON Schema + scripts | 4 files | 7 steps | -| 4 | Skill SKILL.md + references | 5 files | 6 steps | -| 5 | Example .vync files | 4 files | 5 steps | -| 6 | Slash commands | 2 files | 3 steps | -| 7 | Hooks + install/uninstall | 3 files | 8 steps | -| 8 | PLAN.md update | 1 file | 3 steps | - -**Total:** ~25 files, ~44 steps, 8 commits diff --git a/docs/archive/2026-03-08-directory-restructure.md b/docs/archive/2026-03-08-directory-restructure.md deleted file mode 100644 index 504eac6..0000000 --- a/docs/archive/2026-03-08-directory-restructure.md +++ /dev/null @@ -1,369 +0,0 @@ -# Directory Restructure Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Clean up Drawnix leftovers, move `src/shared/` to `packages/shared/`, rename `src/` to `tools/` for clarity in the nx monorepo structure. - -**Architecture:** Three-phase approach — (1) delete Drawnix-only files, (2) extract `packages/shared/` with `@vync/shared` alias, (3) rename `src/` to `tools/` and update all 15 path references. Each phase is independently committable. - -**Tech Stack:** TypeScript path aliases (tsconfig), tsx, esbuild `--alias`, nx monorepo - ---- - -### Task 1: Delete Drawnix Leftovers - -**Files:** -- Delete: `CFPAGE-DEPLOY.md` -- Delete: `CHANGELOG.md` -- Delete: `README_en.md` -- Delete: `SECURITY.md` -- Delete: `Dockerfile` -- Delete: `.dockerignore` -- Delete: `scripts/release-version.js` -- Delete: `scripts/publish.js` -- Delete: `.github/workflows/publish.yml` -- Modify: `.gitignore` -- Modify: `.github/workflows/ci.yml` - -**Step 1: Delete files** - -```bash -git rm CFPAGE-DEPLOY.md CHANGELOG.md README_en.md SECURITY.md Dockerfile .dockerignore -git rm -r scripts/ -git rm .github/workflows/publish.yml -``` - -**Step 2: Add .DS_Store to .gitignore** - -In `.gitignore`, after `Thumbs.db`, the `.DS_Store` entry already exists. Verify no .DS_Store files are tracked: - -```bash -git ls-files '*.DS_Store' -``` - -If any are tracked, run `git rm --cached `. - -**Step 3: Update CI workflow** - -Replace `.github/workflows/ci.yml` with Vync-appropriate version: - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - -permissions: - actions: read - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: 'npm' - - - run: npm ci - - uses: nrwl/nx-set-shas@v4 - - - run: npx nx affected -t lint test build --verbose -``` - -**Step 4: Commit** - -```bash -git add -A -git commit -m "chore: remove Drawnix-specific files and update CI" -``` - ---- - -### Task 2: Create `packages/shared/` from `src/shared/` - -**Files:** -- Create: `packages/shared/src/index.ts` -- Move: `src/shared/types.ts` → `packages/shared/src/types.ts` -- Move: `src/shared/hash.ts` → `packages/shared/src/hash.ts` -- Modify: `tsconfig.base.json` (update `@vync/shared` path) - -**Step 1: Create directory and move files** - -```bash -mkdir -p packages/shared/src -git mv src/shared/types.ts packages/shared/src/types.ts -git mv src/shared/hash.ts packages/shared/src/hash.ts -rmdir src/shared -``` - -**Step 2: Create barrel export** - -Create `packages/shared/src/index.ts`: - -```typescript -export type { VyncFile, VyncViewport, WsMessage } from './types.js'; -export { sha256 } from './hash.js'; -``` - -**Step 3: Update tsconfig.base.json** - -Change line 21: - -``` -"@vync/shared": ["src/shared/types.ts"] -``` - -→ - -``` -"@vync/shared": ["packages/shared/src/index.ts"] -``` - -**Step 4: Create root tsconfig.json** - -tsx needs `tsconfig.json` (not `tsconfig.base.json`) to resolve path aliases at runtime. Create `tsconfig.json`: - -```json -{ - "extends": "./tsconfig.base.json" -} -``` - -Note: `apps/web/tsconfig.json` already extends `../../tsconfig.base.json`, so this doesn't conflict. - -**Step 5: Update imports in src/server/ and src/cli/** - -These files currently use relative `../shared/` imports. Change to `@vync/shared` alias: - -`src/server/server.ts` line 7: -``` -- import type { VyncFile } from '../shared/types.js'; -+ import type { VyncFile } from '@vync/shared'; -``` - -`src/server/sync-service.ts` lines 3-4: -``` -- import { sha256 } from '../shared/hash.js'; -- import type { VyncFile } from '../shared/types.js'; -+ import { sha256 } from '@vync/shared'; -+ import type { VyncFile } from '@vync/shared'; -``` - -(Combine into one import: `import { sha256, type VyncFile } from '@vync/shared';`) - -`src/server/ws-handler.ts` line 3: -``` -- import type { WsMessage } from '../shared/types.js'; -+ import type { WsMessage } from '@vync/shared'; -``` - -`src/cli/init.ts` line 3: -``` -- import type { VyncFile } from '../shared/types.js'; -+ import type { VyncFile } from '@vync/shared'; -``` - -`apps/web/src/app/app.tsx` line 4: already uses `@vync/shared` — no change needed. - -**Step 6: Verify** - -```bash -npx tsx src/server/server.ts --help 2>&1 || true # Should parse without import errors -``` - -**Step 7: Commit** - -```bash -git add -A -git commit -m "refactor: extract packages/shared from src/shared" -``` - ---- - -### Task 3: Rename `src/` → `tools/` - -**Files:** -- Move: `src/server/` → `tools/server/` -- Move: `src/cli/` → `tools/cli/` -- Move: `src/electron/` → `tools/electron/` -- Modify: `bin/vync.js` (line 7) -- Modify: `package.json` (scripts lines 13-15) -- Modify: `tools/cli/open.ts` (line 146) -- Modify: `tools/server/server.ts` (line 181) - -**Step 1: Move directories** - -```bash -mkdir tools -git mv src/server tools/server -git mv src/cli tools/cli -git mv src/electron tools/electron -rmdir src -``` - -**Step 2: Update `bin/vync.js`** - -Line 7: -``` -- const main = path.join(projectRoot, 'src', 'cli', 'main.ts'); -+ const main = path.join(projectRoot, 'tools', 'cli', 'main.ts'); -``` - -**Step 3: Update `package.json` scripts** - -Line 13: -``` -- "dev:server": "tsx src/server/server.ts", -+ "dev:server": "tsx tools/server/server.ts", -``` - -Line 14: -``` -- "dev:desktop": "npx esbuild src/electron/main.ts --bundle --platform=node --outdir=dist/electron --external:electron --packages=external --sourcemap && electron dist/electron/main.js", -+ "dev:desktop": "npx esbuild tools/electron/main.ts --bundle --platform=node --outdir=dist/electron --external:electron --packages=external --alias:@vync/shared=./packages/shared/src/index.ts --sourcemap && electron dist/electron/main.js", -``` - -Line 15: -``` -- "build:desktop": "nx build web && npx esbuild src/electron/main.ts src/electron/preload.ts --bundle --platform=node --outdir=dist/electron --external:electron --packages=external", -+ "build:desktop": "nx build web && npx esbuild tools/electron/main.ts tools/electron/preload.ts --bundle --platform=node --outdir=dist/electron --external:electron --packages=external --alias:@vync/shared=./packages/shared/src/index.ts", -``` - -Note: `--alias:@vync/shared=...` is needed because esbuild does not read tsconfig paths. It resolves `@vync/shared` imports in server code that gets bundled via dynamic import from electron/main.ts. - -**Step 4: Update hardcoded runtime path in `tools/cli/open.ts`** - -Line 146: -``` -- const serverScript = path.join(projectRoot, 'src', 'server', 'server.ts'); -+ const serverScript = path.join(projectRoot, 'tools', 'server', 'server.ts'); -``` - -**Step 5: Update usage message in `tools/server/server.ts`** - -Line 181: -``` -- console.error('Usage: npx tsx src/server/server.ts '); -+ console.error('Usage: npx tsx tools/server/server.ts '); -``` - -**Step 6: Verify relative imports still work** - -These dynamic relative imports are UNCHANGED because the relative positions within tools/ are preserved: -- `tools/cli/open.ts:43` → `import('../server/server.js')` ✓ (../server/ relative to tools/cli/) -- `tools/electron/main.ts:76` → `import('../server/server.js')` ✓ (../server/ relative to tools/electron/) -- `tools/electron/main.ts:107` → `path.join(__dirname, 'preload.js')` ✓ (same directory) -- `tools/cli/main.ts:1-2` → `./init.js`, `./open.js` ✓ (same directory) - -**Step 7: Verify test path** - -The test at `tools/cli/__tests__/init.test.ts` imports `../init.js` — relative, still works. - -**Step 8: Verify** - -```bash -npx tsx tools/server/server.ts --help 2>&1 || true # Should not error on imports -node bin/vync.js --help # Should print usage -``` - -**Step 9: Commit** - -```bash -git add -A -git commit -m "refactor: rename src/ to tools/ for monorepo clarity" -``` - ---- - -### Task 4: Update Documentation - -**Files:** -- Modify: `README.md` -- Modify: `CLAUDE.md` -- Modify: `docs/ARCHITECTURE.md` - -**Step 1: Update README.md architecture section** - -Replace the architecture tree to reflect `tools/` and `packages/shared/`: - -``` -Vync/ -├── apps/web/ # Vite SPA (React 19 + Plait) -├── packages/ -│ ├── drawnix/ # Whiteboard UI library -│ ├── react-board/ # Plait ↔ React bridge -│ ├── react-text/ # Text rendering (Slate) -│ └── shared/ # Shared types and utilities (@vync/shared) -├── tools/ -│ ├── server/ # Express + Vite middleware + WebSocket -│ ├── cli/ # CLI commands (init, open, stop) -│ └── electron/ # Electron main + preload -├── .claude-plugin/ # Claude Code integration (marketplace plugin) -│ ├── plugin.json # Plugin metadata -│ ├── skills/vync-editing/ # AI editing skill -│ ├── commands/ # Slash commands -│ └── hooks.json # PostToolUse + SessionEnd hooks -└── bin/vync.js # CLI entry point -``` - -**Step 2: Update CLAUDE.md** - -Update project structure section: replace `src/server/`, `src/cli/`, `src/electron/`, `src/shared/` with `tools/server/`, `tools/cli/`, `tools/electron/`, `packages/shared/`. Update key commands and path alias. - -**Step 3: Update docs/ARCHITECTURE.md** - -Find the directory tree section (around line 270-318) and update all `src/` references to `tools/` and `packages/shared/`. - -**Step 4: Commit** - -```bash -git add README.md CLAUDE.md docs/ARCHITECTURE.md -git commit -m "docs: update paths for tools/ and packages/shared/ restructure" -``` - ---- - -### Task 5: Final Verification - -**Step 1: Dev server smoke test** - -```bash -npx tsx tools/server/server.ts /tmp/test-verify.vync & -SERVER_PID=$! -sleep 2 -curl -s http://localhost:3100/api/sync | head -c 100 -kill $SERVER_PID -``` - -Expected: JSON response with version, viewport, elements. - -**Step 2: CLI smoke test** - -```bash -node bin/vync.js init /tmp/verify-cli.vync -cat /tmp/verify-cli.vync -``` - -Expected: Valid .vync JSON with version 1. - -**Step 3: Type check (optional)** - -```bash -npx nx build web --skip-nx-cache 2>&1 | tail -5 -``` - -Expected: Build succeeds. - -**Step 4: Clean up test files** - -```bash -rm -f /tmp/test-verify.vync /tmp/verify-cli.vync -``` diff --git a/docs/archive/2026-03-08-electron-desktop-design.md b/docs/archive/2026-03-08-electron-desktop-design.md deleted file mode 100644 index 9bb8e2b..0000000 --- a/docs/archive/2026-03-08-electron-desktop-design.md +++ /dev/null @@ -1,132 +0,0 @@ -# Design: Electron Desktop App Integration - -**Date:** 2026-03-08 -**Status:** Approved - -## Goal - -Vync를 Electron으로 감싸서 네이티브 데스크톱 앱처럼 동작하게 한다. -- `.vync` 파일 더블클릭 → 앱 열림 (서버 자동 시작) -- 창 닫기 → 서버 자동 종료 -- CLI (`vync init/open/stop`) 그대로 유지, `vync open`은 Electron 앱을 실행 - -## Decisions - -| ID | Decision | Rationale | -|----|----------|-----------| -| E-1 | Electron + Express in-process (단일 프로세스) | 서버가 경량. 프로세스 분리는 과잉. | -| E-2 | Dev: Vite middleware / Prod: express.static | 기존 아키텍처 최소 변경. 웹 독립 실행 유지. | -| E-3 | `src/electron/` (not `apps/desktop/`) | src/cli, src/server와 동일 패턴. Nx 프로젝트 불필요. | -| E-4 | esbuild로 main.ts 빌드 | ~50ms, 설정 파일 없음, Vite 내부 포함. | -| E-5 | electron-builder로 macOS DMG 패키징 | 파일 연결 내장, electron-forge 대비 단순. | -| E-6 | macOS: 창 닫기 = 앱 종료 | 파일 편집 세션 도구. 창 = 세션. | -| E-7 | macOS만 타겟 (현재) | 개발 환경 기준. 최소 범위. | - -## Architecture - -``` -[User double-clicks .vync] - → macOS launches Vync.app - → Electron main process - → startServer(filePath, { mode, port }) - → Express + WS + chokidar (in-process) - → BrowserWindow → http://localhost: - → User closes window - → shutdown() → app.quit() - -[User runs `vync open plan.vync`] - → CLI spawns Electron app as detached process - → Same flow as above -``` - -## Project Structure - -``` -src/electron/ - main.ts # Electron main process - preload.ts # Minimal preload (placeholder) - -electron-builder.yml # macOS packaging config -build/ # App icons, resources -``` - -## Key Changes to Existing Code - -### 1. `src/server/server.ts` — API Refactoring - -```typescript -// BEFORE -export async function startServer(resolvedPath: string, options: { openBrowser?: boolean }) - -// AFTER -export async function startServer(resolvedPath: string, options: { - port?: number; // default 3100 - mode?: 'development' | 'production'; - staticDir?: string; // prod: path to dist/apps/web - openBrowser?: boolean; -}): Promise<{ shutdown: () => Promise; server: Server; url: string }> -``` - -Critical fixes: -- Remove `process.exit()` → throw errors -- Remove SIGINT/SIGTERM handlers → caller wires up -- Conditional Vite import (dynamic, dev only) -- `express.static` branch for production -- `server.close()` with timeout + WS client termination -- Error handler on `server.listen` for EADDRINUSE - -### 2. `src/cli/open.ts` — Electron Spawn - -`vync open` spawns Electron app (detached) instead of `tsx server.ts`. -Fallback to current tsx spawn if Electron not available. - -### 3. `apps/web/index.html` — Analytics Gate - -Umami script conditional on non-Electron environment. - -## Electron Main Process (`src/electron/main.ts`) - -Key behaviors: -- `app.requestSingleInstanceLock()` — 단일 인스턴스 -- `app.on('open-file')` — 파일 연결 (ready 전 버퍼링) -- `app.on('window-all-closed')` → `shutdown()` → `app.quit()` -- Port conflict detection: 기존 서버 감지 시 재사용 -- No file argument → `dialog.showOpenDialog()` - -## Build & Scripts - -```json -{ - "dev:desktop": "esbuild src/electron/main.ts --bundle --platform=node --outdir=dist/electron --external:electron && electron dist/electron/main.js", - "build:desktop": "nx build web && esbuild src/electron/main.ts src/electron/preload.ts --bundle --platform=node --outdir=dist/electron --external:electron", - "package:desktop": "npm run build:desktop && electron-builder" -} -``` - -## Pre-requisites (server.ts refactoring) - -Before any Electron code, these must be fixed in `server.ts`: - -1. **CRITICAL**: `process.exit()` → throw -2. **CRITICAL**: SIGINT/SIGTERM handlers → remove from startServer -3. **HIGH**: PORT configurable via options -4. **HIGH**: Vite import conditional (dynamic) -5. **HIGH**: shutdown() properly awaits server.close() with timeout -6. **HIGH**: WS clients terminated before server close - -## Dependencies to Add - -``` -devDependencies: - electron: latest - electron-builder: latest -``` - -esbuild is already available (Vite internal). - -## Out of Scope - -- Windows/Linux support -- Multi-file simultaneous editing -- Code signing / notarization (future) -- Auto-update mechanism diff --git a/docs/archive/2026-03-08-electron-implementation.md b/docs/archive/2026-03-08-electron-implementation.md deleted file mode 100644 index 8cda512..0000000 --- a/docs/archive/2026-03-08-electron-implementation.md +++ /dev/null @@ -1,882 +0,0 @@ -# Electron Desktop App Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Vync를 Electron으로 감싸서 네이티브 데스크톱 앱처럼 동작하게 한다 (.vync 더블클릭 → 앱 열림, 창 닫기 → 자동 종료). - -**Architecture:** Electron main process가 `startServer()`를 in-process로 호출. BrowserWindow가 `http://localhost:`를 로드. 개발 모드에서는 Vite middleware, 프로덕션에서는 `express.static`. CLI (`vync open`)는 Electron 앱을 detached spawn. - -**Tech Stack:** Electron, electron-builder (macOS DMG), esbuild (main.ts 빌드), Express + WS + chokidar (기존 서버) - ---- - -## Task 1: Refactor `startServer()` — Remove `process.exit()` and signal handlers - -**Files:** -- Modify: `src/server/server.ts:12-143` - -**Step 1: Remove `process.exit(1)` from startServer error handling** - -Replace lines 17-26 in `src/server/server.ts`: - -```typescript -// BEFORE - const sync = createSyncService(resolvedPath); - try { - await sync.init(); - } catch (err: any) { - if (err.code === 'ENOENT') { - console.error(`[vync] File not found: ${resolvedPath}`); - } else { - console.error(`[vync] Invalid JSON in file: ${resolvedPath}`); - } - process.exit(1); - } - -// AFTER - const sync = createSyncService(resolvedPath); - try { - await sync.init(); - } catch (err: any) { - const msg = err.code === 'ENOENT' - ? `File not found: ${resolvedPath}` - : `Invalid JSON in file: ${resolvedPath}`; - throw new Error(`[vync] ${msg}`); - } -``` - -**Step 2: Remove SIGINT/SIGTERM handlers from startServer** - -Delete lines 123-124: - -```typescript -// DELETE these lines - process.on('SIGINT', async () => { await shutdown(); process.exit(0); }); - process.on('SIGTERM', async () => { await shutdown(); process.exit(0); }); -``` - -**Step 3: Add signal handlers to the direct-run block instead** - -Update the `isDirectRun` block at the bottom of `server.ts`: - -```typescript -if (isDirectRun) { - const filePath = process.argv[2]; - if (!filePath) { - console.error('Usage: npx tsx src/server/server.ts '); - process.exit(1); - } - startServer(path.resolve(filePath)) - .then(({ shutdown }) => { - process.on('SIGINT', async () => { await shutdown(); process.exit(0); }); - process.on('SIGTERM', async () => { await shutdown(); process.exit(0); }); - }) - .catch((err) => { - console.error('[vync] Fatal error:', err.message); - process.exit(1); - }); -} -``` - -**Step 4: Update `runForeground` in `open.ts` to wire up signal handlers** - -In `src/cli/open.ts`, update `runForeground`: - -```typescript -async function runForeground(resolved: string): Promise { - await fs.mkdir(VYNC_DIR, { recursive: true }); - await fs.writeFile(PID_FILE, String(process.pid), 'utf-8'); - - const { startServer } = await import('../server/server.js'); - const { shutdown } = await startServer(resolved, { openBrowser: true }); - - process.on('SIGINT', async () => { await shutdown(); process.exit(0); }); - process.on('SIGTERM', async () => { await shutdown(); process.exit(0); }); -} -``` - -**Step 5: Verify dev:server still works** - -Run: `npm run dev:server -- examples/mindmap.vync` -Expected: Server starts on :3100, Ctrl+C cleanly shuts down. - -**Step 6: Commit** - -```bash -git add src/server/server.ts src/cli/open.ts -git commit -m "refactor(server): remove process.exit and signal handlers from startServer - -Callers now wire up their own lifecycle management. -This is a prerequisite for Electron integration." -``` - ---- - -## Task 2: Add configurable port and EADDRINUSE error handling - -**Files:** -- Modify: `src/server/server.ts:10-135` - -**Step 1: Update startServer signature with port option** - -```typescript -// BEFORE -const PORT = 3100; - -export async function startServer( - resolvedPath: string, - options: { openBrowser?: boolean } = {} -) { - -// AFTER -const DEFAULT_PORT = 3100; - -export async function startServer( - resolvedPath: string, - options: { - openBrowser?: boolean; - port?: number; - } = {} -) { - const port = options.port ?? DEFAULT_PORT; -``` - -**Step 2: Replace all `PORT` references with `port` inside startServer** - -Update CORS origins, WebSocket server, and listen call to use `port` variable: - -```typescript - const allowedOrigins = [ - `http://localhost:${port}`, - `http://127.0.0.1:${port}`, - ]; - // ... - const ws = createWsServer(server, port); - // ... - const url = `http://localhost:${port}`; -``` - -**Step 3: Add error handler on server.listen for EADDRINUSE** - -Replace the listen promise: - -```typescript -// BEFORE - await new Promise((resolve) => { - server.listen(PORT, '127.0.0.1', () => { - console.log(`[vync] Server running at ${url}`); - // ... - resolve(); - }); - }); - -// AFTER - await new Promise((resolve, reject) => { - server.once('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - reject(new Error(`[vync] Port ${port} is already in use`)); - } else { - reject(err); - } - }); - server.listen(port, '127.0.0.1', () => { - server.removeAllListeners('error'); - console.log(`[vync] Server running at ${url}`); - console.log(`[vync] Watching: ${resolvedPath}`); - console.log(`[vync] WebSocket: ws://localhost:${port}/ws`); - resolve(); - }); - }); -``` - -**Step 4: Verify** - -Run: `npm run dev:server -- examples/mindmap.vync` -Then in another terminal: `npm run dev:server -- examples/mindmap.vync` -Expected: Second instance throws "Port 3100 is already in use" (not a crash). - -**Step 5: Commit** - -```bash -git add src/server/server.ts -git commit -m "feat(server): add configurable port and EADDRINUSE handling" -``` - ---- - -## Task 3: Make Vite import conditional and add production static serving - -**Files:** -- Modify: `src/server/server.ts:4,83-97,115-121` - -**Step 1: Remove top-level Vite import** - -```typescript -// DELETE line 4 -import { createServer as createViteServer } from 'vite'; -``` - -**Step 2: Add mode and staticDir to startServer options** - -```typescript -export async function startServer( - resolvedPath: string, - options: { - openBrowser?: boolean; - port?: number; - mode?: 'development' | 'production'; - staticDir?: string; - } = {} -) { - const port = options.port ?? DEFAULT_PORT; - const mode = options.mode ?? 'development'; -``` - -**Step 3: Replace unconditional Vite setup with dev/prod branch** - -```typescript - // --- Frontend serving (dev: Vite middleware, prod: static files) --- - - let vite: { close: () => Promise } | null = null; - - if (mode === 'production' && options.staticDir) { - app.use(express.static(options.staticDir)); - // SPA fallback: serve index.html for all non-API routes - app.get('*', (_req, res) => { - res.sendFile(path.join(options.staticDir!, 'index.html')); - }); - } else { - const { createServer: createViteServer } = await import('vite'); - const projectRoot = process.env.VYNC_HOME || process.cwd(); - const webAppRoot = path.resolve(projectRoot, 'apps/web'); - - vite = await createViteServer({ - configFile: path.resolve(webAppRoot, 'vite.config.ts'), - root: webAppRoot, - server: { - middlewareMode: true, - hmr: { server }, - }, - }); - - app.use(vite.middlewares); - } -``` - -**Step 4: Update shutdown to conditionally close Vite** - -```typescript - const shutdown = async () => { - console.log('\n[vync] Shutting down...'); - await watcher.close(); - ws.close(); - if (vite) { - await vite.close(); - } - await new Promise((resolve) => { - const timer = setTimeout(resolve, 3000); - server.close(() => { clearTimeout(timer); resolve(); }); - }); - }; -``` - -**Step 5: Verify dev mode still works** - -Run: `npm run dev:server -- examples/mindmap.vync` -Expected: Vite middleware loads, HMR works in browser. - -**Step 6: Verify production mode works** - -Run: `npx nx build web` first, then: -```bash -NODE_ENV=production npx tsx src/server/server.ts examples/mindmap.vync -``` -Note: This won't work yet because the direct-run block doesn't pass mode/staticDir. That's fine — Electron will be the production caller. Just verify dev mode is not broken. - -**Step 7: Commit** - -```bash -git add src/server/server.ts -git commit -m "feat(server): conditional Vite import and production static serving - -Vite is dynamically imported only in development mode. -Production mode serves pre-built static files via express.static." -``` - ---- - -## Task 4: Fix WebSocket client termination and shutdown awaiting - -**Files:** -- Modify: `src/server/ws-handler.ts:40-53` - -**Step 1: Terminate all WebSocket clients before closing server** - -Update the `close` function in `ws-handler.ts`: - -```typescript -// BEFORE - close() { - wss.close(); - }, - -// AFTER - close() { - wss.clients.forEach((client) => client.terminate()); - wss.close(); - }, -``` - -**Step 2: Verify** - -Run: `npm run dev:server -- examples/mindmap.vync` -Open browser to http://localhost:3100, then Ctrl+C. -Expected: Clean shutdown without hanging. - -**Step 3: Commit** - -```bash -git add src/server/ws-handler.ts -git commit -m "fix(ws): terminate all WebSocket clients on shutdown - -Prevents server.close() from hanging due to keep-alive connections." -``` - ---- - -## Task 5: Install Electron dependencies and add build scripts - -**Files:** -- Modify: `package.json` - -**Step 1: Install Electron and electron-builder as devDependencies** - -Run: -```bash -npm install --save-dev electron electron-builder -``` - -**Step 2: Add Electron scripts to package.json** - -Add to the `"scripts"` section: - -```json -"dev:desktop": "node node_modules/esbuild/bin/esbuild src/electron/main.ts --bundle --platform=node --outdir=dist/electron --external:electron --sourcemap && electron dist/electron/main.js", -"build:desktop": "nx build web && node node_modules/esbuild/bin/esbuild src/electron/main.ts src/electron/preload.ts --bundle --platform=node --outdir=dist/electron --external:electron", -"package:desktop": "npm run build:desktop && electron-builder" -``` - -Note: We use `node node_modules/esbuild/bin/esbuild` because esbuild is already available via Vite's dependencies (no separate install needed). - -**Step 3: Add `"main"` field to package.json** - -Add at the top level of package.json: - -```json -"main": "dist/electron/main.js" -``` - -**Step 4: Verify esbuild is available** - -Run: `node node_modules/esbuild/bin/esbuild --version` -Expected: Prints a version number (esbuild is bundled with Vite). - -**Step 5: Commit** - -```bash -git add package.json package-lock.json -git commit -m "chore: add Electron and electron-builder dependencies" -``` - ---- - -## Task 6: Create Electron main process - -**Files:** -- Create: `src/electron/main.ts` - -**Step 1: Write the Electron main process** - -Create `src/electron/main.ts`: - -```typescript -import { app, BrowserWindow, dialog } from 'electron'; -import path from 'node:path'; - -let mainWindow: BrowserWindow | null = null; -let serverHandle: { shutdown: () => Promise; url: string } | null = null; -let pendingFilePath: string | null = null; - -// --- Single instance lock --- -const gotTheLock = app.requestSingleInstanceLock(); -if (!gotTheLock) { - app.quit(); -} else { - app.on('second-instance', (_event, argv) => { - const file = argv.find((a) => a.endsWith('.vync')); - if (file) openFile(file); - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - } - }); -} - -// --- macOS: handle file association (can fire before ready) --- -app.on('open-file', (event, filePath) => { - event.preventDefault(); - if (mainWindow) { - openFile(filePath); - } else { - pendingFilePath = filePath; - } -}); - -// --- App ready --- -app.whenReady().then(async () => { - const filePath = - pendingFilePath || - process.argv.find((a) => a.endsWith('.vync')) || - null; - - if (!filePath) { - const result = await dialog.showOpenDialog({ - filters: [{ name: 'Vync Canvas', extensions: ['vync'] }], - properties: ['openFile'], - }); - if (result.canceled || result.filePaths.length === 0) { - app.quit(); - return; - } - await openFile(result.filePaths[0]); - } else { - await openFile(filePath); - } -}); - -// --- Window lifecycle --- -app.on('window-all-closed', async () => { - if (serverHandle) { - await serverHandle.shutdown(); - serverHandle = null; - } - app.quit(); -}); - -// --- Core functions --- - -async function openFile(filePath: string): Promise { - const resolved = path.resolve(filePath); - - // Shut down existing server if running - if (serverHandle) { - await serverHandle.shutdown(); - serverHandle = null; - } - - try { - const { startServer } = await import('../server/server.js'); - - const isDev = !app.isPackaged; - const staticDir = isDev - ? undefined - : path.join(process.resourcesPath, 'dist', 'apps', 'web'); - - serverHandle = await startServer(resolved, { - port: 3100, - mode: isDev ? 'development' : 'production', - staticDir, - }); - } catch (err: any) { - dialog.showErrorBox('Vync Error', err.message); - app.quit(); - return; - } - - if (!mainWindow) { - createWindow(serverHandle.url); - } else { - mainWindow.loadURL(serverHandle.url); - } -} - -function createWindow(url: string): void { - mainWindow = new BrowserWindow({ - width: 1400, - height: 900, - title: 'Vync', - webPreferences: { - preload: path.join(__dirname, 'preload.js'), - contextIsolation: true, - nodeIntegration: false, - }, - }); - - mainWindow.loadURL(url); - - mainWindow.on('closed', () => { - mainWindow = null; - }); -} -``` - -**Step 2: Verify the file compiles** - -Run: `node node_modules/esbuild/bin/esbuild src/electron/main.ts --bundle --platform=node --outdir=dist/electron --external:electron --sourcemap` -Expected: `dist/electron/main.js` created without errors. - -**Step 3: Commit** - -```bash -git add src/electron/main.ts -git commit -m "feat(electron): create main process entry point - -Single instance lock, macOS open-file handling, file picker dialog, -dev/prod mode detection via app.isPackaged." -``` - ---- - -## Task 7: Create Electron preload script - -**Files:** -- Create: `src/electron/preload.ts` - -**Step 1: Write minimal preload script** - -Create `src/electron/preload.ts`: - -```typescript -import { contextBridge } from 'electron'; - -contextBridge.exposeInMainWorld('vyncDesktop', { - isDesktopApp: true, -}); -``` - -**Step 2: Verify it compiles** - -Run: `node node_modules/esbuild/bin/esbuild src/electron/preload.ts --bundle --platform=node --outdir=dist/electron --external:electron` -Expected: `dist/electron/preload.js` created. - -**Step 3: Commit** - -```bash -git add src/electron/preload.ts -git commit -m "feat(electron): add minimal preload script" -``` - ---- - -## Task 8: Create electron-builder configuration - -**Files:** -- Create: `electron-builder.yml` - -**Step 1: Write electron-builder config** - -Create `electron-builder.yml` at project root: - -```yaml -appId: com.vync.app -productName: Vync - -directories: - output: dist/packages - buildResources: build - -mac: - category: public.app-category.productivity - target: dmg - -dmg: - title: Vync - iconSize: 80 - contents: - - x: 130 - y: 220 - - x: 410 - y: 220 - type: link - path: /Applications - -fileAssociations: - - ext: vync - name: Vync Canvas - role: Editor - -files: - - dist/electron/**/* - - dist/apps/web/**/* - - node_modules/**/* - - "!node_modules/**/{test,tests,__tests__}/**" - - "!node_modules/**/*.{md,map,ts}" - - "!**/node_modules/.cache/**" - -extraMetadata: - main: dist/electron/main.js -``` - -**Step 2: Create build resources directory** - -Run: `mkdir -p build` - -**Step 3: Commit** - -```bash -git add electron-builder.yml -git commit -m "chore: add electron-builder config for macOS DMG packaging" -``` - ---- - -## Task 9: Update CLI to spawn Electron app - -**Files:** -- Modify: `src/cli/open.ts:47-126` - -**Step 1: Add Electron spawn function** - -Add `runElectron` function in `open.ts`, before `runDaemon`: - -```typescript -async function findElectronBinary(): Promise { - const projectRoot = process.env.VYNC_HOME || process.cwd(); - const electronPath = path.join(projectRoot, 'node_modules', '.bin', 'electron'); - const compiledMain = path.join(projectRoot, 'dist', 'electron', 'main.js'); - - try { - await fs.access(electronPath, fsSync.constants.X_OK); - await fs.access(compiledMain); - return electronPath; - } catch { - return null; - } -} - -async function runElectron(resolved: string): Promise { - const projectRoot = process.env.VYNC_HOME || process.cwd(); - const electronPath = path.join(projectRoot, 'node_modules', '.bin', 'electron'); - const compiledMain = path.join(projectRoot, 'dist', 'electron', 'main.js'); - - await fs.mkdir(VYNC_DIR, { recursive: true }); - const logFd = fsSync.openSync(LOG_FILE, 'w'); - - const child = spawn(electronPath, [compiledMain, resolved], { - detached: true, - stdio: ['ignore', logFd, logFd], - cwd: projectRoot, - env: { ...process.env, VYNC_HOME: projectRoot }, - }); - - const childPid = child.pid; - if (!childPid) { - fsSync.closeSync(logFd); - console.error('[vync] Failed to spawn Electron process.'); - process.exit(1); - } - - await fs.writeFile(PID_FILE, String(childPid), 'utf-8'); - child.unref(); - fsSync.closeSync(logFd); - - // Poll until server is ready - const url = `http://localhost:${PORT}`; - const startTime = Date.now(); - - while (Date.now() - startTime < POLL_TIMEOUT) { - try { - process.kill(childPid, 0); - } catch { - console.error('[vync] Electron process exited unexpectedly.'); - console.error(`[vync] Check logs: ${LOG_FILE}`); - await fs.unlink(PID_FILE).catch(() => {}); - process.exit(1); - } - - try { - const res = await fetch(`${url}/api/sync`); - if (res.ok) { - console.log(`[vync] Vync app running (PID ${childPid})`); - console.log(`[vync] Watching: ${resolved}`); - console.log(`[vync] Log: ${LOG_FILE}`); - return; - } - } catch { - // Not ready yet - } - - await new Promise((r) => setTimeout(r, POLL_INTERVAL)); - } - - console.error(`[vync] Electron did not become ready within ${POLL_TIMEOUT / 1000}s.`); - console.error(`[vync] Check logs: ${LOG_FILE}`); - await fs.unlink(PID_FILE).catch(() => {}); - process.exit(1); -} -``` - -**Step 2: Update runDaemon dispatch to try Electron first** - -Update `vyncOpen` to try Electron, fall back to daemon: - -```typescript -export async function vyncOpen( - filePath: string, - opts: { foreground?: boolean } = {} -): Promise { - const resolved = await validateAndResolve(filePath); - await ensureNoExistingServer(); - - if (opts.foreground) { - return runForeground(resolved); - } - - // Try Electron first, fall back to daemon mode - const electronBinary = await findElectronBinary(); - if (electronBinary) { - return runElectron(resolved); - } - return runDaemon(resolved); -} -``` - -**Step 3: Verify CLI still works without Electron build** - -Run: `rm -rf dist/electron` then `npx tsx src/cli/main.ts open examples/mindmap.vync` -Expected: Falls back to daemon mode (tsx server.ts spawn). - -**Step 4: Commit** - -```bash -git add src/cli/open.ts -git commit -m "feat(cli): vync open spawns Electron app with daemon fallback - -Tries Electron binary first. If dist/electron/main.js doesn't exist, -falls back to current tsx daemon mode." -``` - ---- - -## Task 10: Gate analytics script for Electron - -**Files:** -- Modify: `apps/web/index.html:72` - -**Step 1: Wrap Umami script in environment check** - -Replace line 72: - -```html - - - - - -``` - -**Step 2: Commit** - -```bash -git add apps/web/index.html -git commit -m "fix(web): gate analytics script for Electron desktop app - -window.vyncDesktop is set by Electron preload script. -Prevents analytics from loading in desktop app context." -``` - ---- - -## Task 11: End-to-end verification - -**Step 1: Build Electron main process** - -Run: -```bash -node node_modules/esbuild/bin/esbuild src/electron/main.ts src/electron/preload.ts --bundle --platform=node --outdir=dist/electron --external:electron --sourcemap -``` -Expected: `dist/electron/main.js` and `dist/electron/preload.js` created. - -**Step 2: Run Electron in dev mode** - -Run: -```bash -npx electron dist/electron/main.js examples/mindmap.vync -``` -Expected: -- Electron window opens (not a browser tab) -- Vync canvas loads with mindmap content -- HMR works (edit a React component, see instant update) -- No Umami analytics script loaded (check DevTools Network tab) - -**Step 3: Test CLI → Electron flow** - -Run: -```bash -npx tsx src/cli/main.ts open examples/mindmap.vync -``` -Expected: -- Electron app spawns as daemon -- CLI prints PID and exits -- `vync stop` terminates the Electron process - -**Step 4: Test window close → server shutdown** - -Open Electron via Step 2, then close the window. -Expected: Process exits cleanly (check `ps aux | grep electron`). - -**Step 5: Test file picker (no file argument)** - -Run: -```bash -npx electron dist/electron/main.js -``` -Expected: macOS file picker dialog opens, filtered to .vync files. - -**Step 6: Test production build (optional)** - -Run: -```bash -npm run build:desktop -npx electron dist/electron/main.js examples/mindmap.vync -``` -Note: This tests the full build pipeline (nx build web + esbuild). -Expected: Electron loads the production-built static files. - -**Step 7: Test DMG packaging (optional)** - -Run: -```bash -npm run package:desktop -``` -Expected: `dist/packages/Vync-*.dmg` created. Opening the DMG shows Vync.app. - -**Step 8: Commit all verification results** - -If any fixes were needed during verification, commit them as separate fix commits. - ---- - -## Summary - -| Task | Description | Est. | -|------|-------------|------| -| 1 | Remove process.exit + signal handlers from startServer | 5 min | -| 2 | Configurable port + EADDRINUSE handling | 5 min | -| 3 | Conditional Vite import + production static serving | 10 min | -| 4 | Fix WS client termination + shutdown awaiting | 3 min | -| 5 | Install Electron deps + add build scripts | 3 min | -| 6 | Create Electron main process | 10 min | -| 7 | Create Electron preload script | 2 min | -| 8 | Create electron-builder config | 3 min | -| 9 | Update CLI to spawn Electron | 10 min | -| 10 | Gate analytics script | 3 min | -| 11 | End-to-end verification | 15 min | diff --git a/docs/archive/2026-03-09-diff-aware-read.md b/docs/archive/2026-03-09-diff-aware-read.md deleted file mode 100644 index 7e5e6c0..0000000 --- a/docs/archive/2026-03-09-diff-aware-read.md +++ /dev/null @@ -1,270 +0,0 @@ -# Diff-Aware `/vync read` 구현 계획 - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** `/vync read` 호출 시 마지막 읽기 이후 웹에서 변경된 내용을 diff로 보고하여, Claude Code가 "무엇이 바뀌었는지" 인식할 수 있게 한다. - -**Architecture:** .vync 파일과 나란히 `.lastread` 스냅샷 파일을 저장한다. sub-agent가 Read 시 두 파일을 비교하여 변경사항을 prose로 보고한다. TypeScript 코드 변경 없이, 에이전트 프롬프트와 커맨드 정의만 수정한다. - -**Tech Stack:** Claude Code agent prompt (markdown), Bash (snapshot 관리) - ---- - -## 핵심 설계 - -### 스냅샷 파일 - -- **위치**: `.vync/.vync` → `.vync/.vync.lastread` -- **포맷**: 동일한 VyncFile JSON (원본 복사) -- **생명주기**: `/vync read`, `/vync create`, `/vync update` 성공 시 갱신 -- **gitignore**: `*.lastread` 추가 (세션 로컬 아티팩트) - -### 데이터 흐름 - -``` -[첫 Read] (스냅샷 없음) - sub-agent: .vync Read → prose 반환 → .lastread Write - 출력: "mindmap: 프로젝트 > [기획, 개발]" - -[웹에서 수정 후 Read] (스냅샷 있음) - sub-agent: .vync Read + .lastread Read → 비교 → diff + prose 반환 → .lastread 갱신 - 출력: "mindmap: 프로젝트 > [기획, 개발, +테스팅, +배포] (변경: 테스팅, 배포 추가)" - -[Create/Update 후] - sub-agent: 파일 쓰기 → .lastread도 갱신 (Claude가 만든 상태 = 기준선) -``` - -### 반환 포맷 확장 - -| 상황 | 현재 | 변경 후 | -|------|------|---------| -| 첫 Read (스냅샷 없음) | `"mindmap: A > [B, C]"` | `"mindmap: A > [B, C]"` (동일) | -| 변경 없음 | `"mindmap: A > [B, C]"` | `"mindmap: A > [B, C] (변경 없음)"` | -| 변경 있음 | `"mindmap: A > [B, C, D]"` | `"mindmap: A > [B, C, +D] (변경: D 추가)"` | -| 노드 삭제 | — | `"mindmap: A > [B] (변경: C 삭제)"` | -| 이름 변경 | — | `"mindmap: A > [기획/일정, 개발] (변경: 기획→기획/일정)"` | - ---- - -## Task 1: .gitignore에 `*.lastread` 추가 - -**Files:** -- Modify: `.gitignore:44` (Vync 섹션) - -**Step 1: .gitignore 수정** - -`.gitignore`의 Vync 섹션에 `*.lastread` 추가: - -``` -# Vync -*.vync -.vync/ -*.lastread -``` - -**Step 2: Commit** - -```bash -git add .gitignore -git commit -m "chore: gitignore .lastread snapshot files" -``` - ---- - -## Task 2: vync-translator 에이전트 Read 절차 업데이트 - -**Files:** -- Modify: `.claude-plugin/agents/vync-translator.md` - -**Step 1: 반환 포맷에 diff 형식 추가** - -`## 반환 포맷` 섹션의 read 항목을 확장: - -```markdown -**성공 시**: 한 줄 요약만 반환. 추가 설명 불필요. -- create: `"mindmap: 프로젝트 > [기획, 개발, 출시]"` -- read (첫 읽기): `"mindmap: 프로젝트 > [기획(시장조사, 인터뷰), 개발(FE, BE)]"` -- read (변경 있음): `"mindmap: 프로젝트 > [기획, 개발, +테스팅] (변경: 테스팅 추가)"` -- read (변경 없음): `"mindmap: 프로젝트 > [기획, 개발] (변경 없음)"` -- update: `"updated: 개발 > [FE, BE, +테스트, +CI/CD]"` -``` - -**Step 2: Read 절차에 스냅샷 비교 로직 추가** - -`### Read` 섹션을 교체: - -```markdown -### Read -1. .vync 파일 Read (현재 상태) -2. 스냅샷 파일 확인: `.lastread` 존재 여부 체크 (Bash: `test -f`) -3. **스냅샷 없음** (첫 읽기): - a. JSON 파싱하여 elements 분석 - b. 구조를 2단계 깊이까지 요약 - c. 한 줄 요약 반환 - d. 현재 .vync 내용을 `.lastread`에 Write (스냅샷 생성) -4. **스냅샷 있음** (후속 읽기): - a. 스냅샷 파일 Read - b. 현재 elements와 스냅샷 elements 비교: - - 추가된 노드: 스냅샷에 없는 id - - 삭제된 노드: 현재에 없는 id - - 변경된 노드: 같은 id지만 text/children이 다름 - c. 변경사항이 있으면 diff 포함하여 요약, 없으면 "(변경 없음)" 표시 - d. 현재 .vync 내용을 `.lastread`에 Write (스냅샷 갱신) -``` - -**Step 3: Create/Update 절차에 스냅샷 갱신 추가** - -`### Create`의 6번(서버 열기) 뒤에 추가: - -```markdown -7. 스냅샷 갱신: 작성한 .vync 내용을 `.lastread`에 Write -``` - -`### Update`의 5번(서버 열기) 뒤에 추가: - -```markdown -6. 스냅샷 갱신: 수정된 .vync 내용을 `.lastread`에 Write -``` - -**Step 4: Commit** - -```bash -git add .claude-plugin/agents/vync-translator.md -git commit -m "feat(plugin): add diff-aware read to vync-translator agent" -``` - ---- - -## Task 3: vync 커맨드 Read 섹션 업데이트 - -**Files:** -- Modify: `.claude-plugin/commands/vync.md` - -**Step 1: Read 설명 업데이트** - -`### Read` 섹션의 Agent 호출 프롬프트에 diff 컨텍스트 추가: - -```markdown -### Read - -1. 파일경로 해결 -2. Agent tool 호출: - -\``` -Agent({ - description: "Vync read file", - subagent_type: "vync-translator", - prompt: "## 작업: Read\n파일: " -}) -\``` - -3. Sub-agent의 prose 요약을 대화에 통합 - - diff가 포함된 경우 ("변경: ..."), 변경 내용을 대화 맥락에 반영 - - "(변경 없음)"인 경우, 현재 상태만 참고 -``` - -**Step 2: Commit** - -```bash -git add .claude-plugin/commands/vync.md -git commit -m "feat(plugin): update vync read command for diff-aware output" -``` - ---- - -## Task 4: Sub-agent 설계 문서 업데이트 - -**Files:** -- Modify: `docs/plans/2026-03-09-subagent-translator-design.md` - -**Step 1: §2 읽기 데이터 흐름 업데이트** - -`**읽기 (Read):**` 섹션을 교체: - -```markdown -**읽기 (Read):** -\``` -사용자: "현재 마인드맵 확인해줘" (또는 "웹에서 수정했어, 뭐가 바뀌었는지 봐줘") - → 메인 세션: 파일경로 해결 → Agent tool: vync-translator spawn - → sub-agent: .vync 파일 Read → .lastread 스냅샷 확인 - → [스냅샷 있음] 현재 vs 스냅샷 비교 → diff 포함 요약 - → [스냅샷 없음] 구조 분석 (2단계 깊이) - → sub-agent 반환: "mindmap: 프로젝트 > [기획, 개발, +테스팅] (변경: 테스팅 추가)" - → .lastread 스냅샷 갱신 - → 메인 세션: prose + diff 전달, 대화 계속 -\``` -``` - -**Step 2: Commit** - -```bash -git add docs/plans/2026-03-09-subagent-translator-design.md -git commit -m "docs: update subagent design for diff-aware read (P1)" -``` - ---- - -## Task 5: E2E 수동 검증 - -설치 후 전체 흐름 검증. - -**Step 1: 플러그인 재설치** - -```bash -bash .claude-plugin/install.sh -``` - -**Step 2: 검증 시나리오 실행** - -| # | 시나리오 | 명령 | 기대 결과 | -|---|---------|------|-----------| -| 1 | 첫 Create | `/vync create mindmap "테스트 계획: 단위, 통합, E2E"` | 파일 생성 + `.lastread` 생성 | -| 2 | 즉시 Read | `/vync read` | 현재 상태 반환 + "(변경 없음)" | -| 3 | 웹에서 수정 | 브라우저에서 노드 추가 | .vync 파일 변경, .lastread 변경 없음 | -| 4 | Read (diff) | `/vync read` | diff 포함 반환: "+추가된노드 (변경: ...)" | -| 5 | Update 후 Read | `/vync update "성능 테스트 추가"` → `/vync read` | "(변경 없음)" (Update가 스냅샷 갱신했으므로) | - -**Step 3: 결과 기록** - -각 시나리오의 PASS/FAIL을 기록. FAIL 시 에이전트 프롬프트 수정 후 재검증. - ---- - -## Task 6: 문서 동기화 - -**Files:** -- Modify: `docs/PLAN.md` — Phase 8 또는 P1 기록 추가 -- Modify: `CLAUDE.md` — 필요 시 diff read 언급 추가 - -**Step 1: PLAN.md에 P1 기록** - -Phase 7 이후에 P1 완료 기록 추가. - -**Step 2: Commit** - -```bash -git add docs/PLAN.md CLAUDE.md -git commit -m "docs: record P1 diff-aware read completion" -``` - ---- - -## 리스크 및 완화 - -| 리스크 | 확률 | 완화 | -|--------|------|------| -| Sub-agent가 JSON diff를 잘못 분석 | 중 | 비교 기준을 `id` 필드로 한정하여 단순화 | -| 큰 파일에서 두 파일 읽기 context 부담 | 낮 | 현실적 사용 범위에서 .vync 파일은 수~수십 KB | -| .lastread 파일이 고아(orphan)로 남음 | 낮 | SessionEnd hook 또는 `vync stop`에서 정리 가능 (후속) | -| 스냅샷 갱신 실패 시 false diff | 낮 | 에이전트 프롬프트에 "Write 실패 시 경고" 포함 | - ---- - -## 범위 외 (의도적 제외) - -| 항목 | 사유 | -|------|------| -| 서버 사이드 diff 계산 | TypeScript 변경 불필요, LLM이 더 유연하게 비교 가능 | -| 요소별 버전 추적 | P2 (FUTURE.md §4-1) 범위 | -| 실시간 변경 알림 | Claude Code 플랫폼 제약 (hook은 외부 변경 미감지) | -| .lastread 자동 정리 | 후속 과제, 파일 크기 미미 | diff --git a/docs/archive/2026-03-09-marketplace-registration.md b/docs/archive/2026-03-09-marketplace-registration.md deleted file mode 100644 index 48b17b1..0000000 --- a/docs/archive/2026-03-09-marketplace-registration.md +++ /dev/null @@ -1,239 +0,0 @@ -# Design: Claude Code Marketplace 등록 - -**Date:** 2026-03-09 -**Status:** Draft -**Depends on:** Phase 9 완료 (Multi-Tab UI) - -## Goal - -Vync Claude Code 플러그인을 마켓플레이스에 등록하여, 프로젝트를 clone하지 않고도 `/plugin install vync`로 설치하고 `/plugin update`로 업데이트할 수 있도록 한다. - -현재 설치 방식 (npm postinstall → install.sh 심링크)은 개발자 전용. 마켓플레이스 등록으로 일반 사용자도 접근 가능하게 확장. - -## 리서치 결과 - -### 마켓플레이스 시스템 분석 - -- Claude Code 마켓플레이스는 **GitHub repo 기반 self-hosted** 방식이 표준 -- claude-hud, everything-claude-code 등 주요 플러그인 모두 동일 패턴 사용 -- `.claude-plugin/`에는 `plugin.json` + `marketplace.json`만 위치 -- commands, skills, agents, hooks는 **프로젝트 루트 레벨**에 위치해야 함 - -### Repo 크기 이슈 - -- Vync repo: working tree ~3GB (node_modules, dist 포함), `.git` 4.4MB -- 마켓플레이스 clone 시 `.gitignore` 대상은 제외됨 → git tracked 파일만 clone -- 실제 clone 크기: 수십 MB 수준 → 문제 없음 -- 비교: claude-hud ~2.6MB, everything-claude-code ~46MB, claude-plugins-official ~7.7MB - -### 설치 흐름 - -``` -사용자: /plugin marketplace add PresenceWith/Vync - → GitHub repo clone → ~/.claude/plugins/marketplaces/PresenceWith-Vync/ - -사용자: /plugin install vync@PresenceWith-Vync - → marketplace에서 plugin 복사 → ~/.claude/plugins/cache/PresenceWith-Vync/vync// - → commands, skills, agents, hooks 자동 등록 - -사용자: /plugin update vync@PresenceWith-Vync - → marketplace git pull → 새 버전 복사 → 자동 재등록 -``` - -## Decisions - -| ID | Decision | Rationale | -|----|----------|-----------| -| P-1 | 같은 repo에서 self-hosted marketplace | 코드와 플러그인 한 repo, 버전 동기화 쉬움. 업계 표준 패턴 (claude-hud, ECC 동일). | -| P-2 | 루트 레벨 파일 재배치 | 마켓플레이스 표준 필수사항. `.claude-plugin/` 안에서 루트로 이동. | -| P-3 | hooks.json validate 인라인화 | 마켓플레이스 설치 시 `$HOME/.claude/skills/` 경로가 달라짐. 인라인 node 스크립트로 경로 독립성 확보. | -| P-4 | 개발자 모드 (install.sh) 병행 유지 | npm install → postinstall로 기존 개발자 워크플로우 유지. 마켓플레이스와 충돌 없음. | -| P-5 | Anthropic Official Directory는 안정화 후 | 먼저 self-hosted로 검증, 이후 공식 제출 (clau.de/plugin-directory-submission). | - -## Architecture - -### 현재 구조 → 목표 구조 - -``` -# 현재 (.claude-plugin/ 안에 모든 것) # 목표 (마켓플레이스 표준) -.claude-plugin/ .claude-plugin/ -├── plugin.json ├── plugin.json ← 수정 -├── hooks.json ├── marketplace.json ← 신규 -├── install.sh ├── install.sh ← 경로 수정 -├── uninstall.sh └── uninstall.sh ← 확인 -├── agents/ agents/ ← 루트로 이동 -│ └── vync-translator.md └── vync-translator.md -├── commands/ commands/ ← 루트로 이동 -│ └── vync.md └── vync.md -└── skills/ skills/ ← 루트로 이동 - └── vync-editing/ └── vync-editing/ - ├── SKILL.md └── (전체 유지) - ├── references/ (4) hooks/ ← 루트에 신규 - ├── scripts/ (2) └── hooks.json ← 인라인화 - └── assets/ (3) -``` - -### hooks.json 인라인화 - -현재 PostToolUse hook이 `$HOME/.claude/skills/vync-editing/scripts/validate.js`를 참조. -마켓플레이스 설치 시 이 경로는 존재하지 않음 (플러그인이 `~/.claude/plugins/cache/`에 설치됨). - -해결: validate.js 핵심 로직 (version, viewport, elements 검증)을 인라인 node 스크립트로 변환. -validate.js 원본은 skills/vync-editing/scripts/에 유지 (sub-agent가 명시적으로 호출 가능). - -## Implementation Steps - -### Step 1: 파일 재배치 (git mv) - -```bash -git mv .claude-plugin/commands commands -git mv .claude-plugin/skills skills -git mv .claude-plugin/agents agents -mkdir hooks -git mv .claude-plugin/hooks.json hooks/hooks.json -``` - -### Step 2: plugin.json 업데이트 - -`.claude-plugin/plugin.json`: - -```json -{ - "name": "vync", - "description": "Visual planning tool with real-time file sync. Create and edit mindmaps, flowcharts, and diagrams from Claude Code.", - "version": "0.1.0", - "author": { - "name": "PresenceWith", - "url": "https://github.com/PresenceWith" - }, - "homepage": "https://github.com/PresenceWith/Vync", - "repository": "https://github.com/PresenceWith/Vync", - "license": "MIT", - "keywords": ["whiteboard", "mindmap", "flowchart", "diagram", "visual-planning", "file-sync"] -} -``` - -### Step 3: marketplace.json 생성 - -`.claude-plugin/marketplace.json`: - -```json -{ - "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", - "name": "PresenceWith-Vync", - "description": "Visual planning tool with real-time file sync for Claude Code", - "owner": { - "name": "PresenceWith", - "email": "presence042@gmail.com" - }, - "plugins": [ - { - "name": "vync", - "source": "./", - "description": "Create and edit mindmaps, flowcharts, and diagrams with real-time file sync between Claude Code and web UI", - "version": "0.1.0", - "author": { - "name": "PresenceWith", - "url": "https://github.com/PresenceWith" - }, - "homepage": "https://github.com/PresenceWith/Vync", - "repository": "https://github.com/PresenceWith/Vync", - "license": "MIT", - "category": "productivity", - "tags": ["whiteboard", "mindmap", "flowchart", "diagram", "visual-planning", "file-sync"] - } - ] -} -``` - -### Step 4: hooks.json 수정 - -`hooks/hooks.json` — PostToolUse validate를 인라인 스크립트로 변경: - -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "<인라인 node 검증 스크립트>" - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "<기존 Hub Server 정리 스크립트 유지>" - } - ] - } - ] - } -} -``` - -### Step 5: install.sh 업데이트 - -경로를 `$SCRIPT_DIR/` → `$PROJECT_ROOT/` 기준으로 변경: -- skills: `$PROJECT_ROOT/skills/vync-editing` -- commands: `$PROJECT_ROOT/commands/vync.md` -- agents: `$PROJECT_ROOT/agents/vync-translator.md` -- hooks: `$PROJECT_ROOT/hooks/hooks.json` - -### Step 6: CLAUDE.md 업데이트 - -플러그인 섹션 경로 설명을 마켓플레이스 표준 구조로 업데이트. - -## Files to Modify - -| File | Action | -|------|--------| -| `commands/vync.md` | git mv from `.claude-plugin/commands/` | -| `skills/vync-editing/` | git mv (전체 디렉토리) | -| `agents/vync-translator.md` | git mv from `.claude-plugin/agents/` | -| `hooks/hooks.json` | git mv + PostToolUse 인라인화 | -| `.claude-plugin/plugin.json` | author → object, homepage 추가 | -| `.claude-plugin/marketplace.json` | 새로 생성 | -| `.claude-plugin/install.sh` | 경로 $PROJECT_ROOT/ 기준 변경 | -| `.claude-plugin/uninstall.sh` | 확인 (변경 최소) | -| `CLAUDE.md` | 플러그인 구조 설명 업데이트 | - -## Verification - -1. `/plugin validate .` → 플러그인 매니페스트 유효성 확인 -2. 로컬 마켓플레이스 테스트: - ```bash - /plugin marketplace add /Users/presence/projects/Vync - /plugin install vync@PresenceWith-Vync - ``` -3. 기능 확인: - - `/vync` 명령어 전체 (init, open, close, stop, create, read, update) - - `vync-editing` 스킬 로드 - - `vync-translator` 에이전트 인식 - - PostToolUse hook (.vync 검증) -4. 개발자 모드: `npm install` → install.sh 정상 동작 -5. GitHub 마켓플레이스 (push 후): - ```bash - /plugin marketplace add PresenceWith/Vync - /plugin install vync@PresenceWith-Vync - ``` - -## User Installation Workflow (Result) - -```bash -# 1회: 마켓플레이스 등록 -/plugin marketplace add PresenceWith/Vync - -# 플러그인 설치 -/plugin install vync@PresenceWith-Vync - -# 업데이트 -/plugin update vync@PresenceWith-Vync -``` diff --git a/docs/archive/2026-03-09-multi-file-hub-design.md b/docs/archive/2026-03-09-multi-file-hub-design.md deleted file mode 100644 index a05b06d..0000000 --- a/docs/archive/2026-03-09-multi-file-hub-design.md +++ /dev/null @@ -1,493 +0,0 @@ -# Design: Multi-File Hub Server - -**Date:** 2026-03-09 -**Status:** Approved -**Decision:** D-014 (Multi-file hub server) - -## Goal - -여러 `.vync` 파일을 동시에 열고 편집할 수 있도록 서버를 단일 파일 → 허브 아키텍처로 전환한다. - -- `vync open A.vync` + `vync open B.vync` → 두 파일 모두 동시 접근 가능 -- 각 브라우저 탭/Electron 윈도우에서 서로 다른 파일을 볼 수 있음 -- 서버는 하나(:3100), 파일은 여러 개 - -## Implementation Stages - -| Stage | Scope | Deliverable | -|-------|-------|-------------| -| **1단계** | 허브 서버 + 멀티 윈도우 | 서버 리팩토링, CLI 변경, 프론트엔드 `?file=` 지원. 브라우저 탭 여러 개로 다른 파일 동시 접근 | -| **2단계** | 멀티 탭 UI | 프론트엔드 탭 바, Electron 멀티 윈도우. **1단계 완료 후 계획 문서 동기화 및 업데이트 필수** | - -> **중요:** 2단계 계획은 1단계 결과에 따라 반드시 재검토한다. 1단계에서 API 계약, WsMessage 프로토콜, 컴포넌트 구조가 확정되면 이를 기반으로 2단계 세부 계획을 업데이트한다. - -## Decisions - -| ID | Decision | Rationale | -|----|----------|-----------| -| M-1 | 허브 서버 (단일 서버, 다중 파일) | 멀티 인스턴스 대비 리소스 효율적. Vite 1개로 충분. 탭+윈도우 모두 가능한 유일한 선택. | -| M-2 | 명시적 파일 등록 (`vync open`) | 파일이 여러 디렉토리에 분산됨. 디렉토리 감시 불가. CLI 호출 패턴과 완벽 일치. | -| M-3 | 절대경로를 파일 식별자로 사용 | 자가복구 아님(제거됨). 프론트엔드 POST 재등록으로 대체. CLI가 서버 없이 URL 구성 가능. 디버깅 가독성. | -| M-4 | 자가복구(auto-register on GET) 제거 | GET은 부작용 없어야 함(REST). LFI 보안 취약점. 프론트엔드 명시적 POST로 대체. | -| M-5 | 하위 호환 폐기 (`?file=` 필수) | 암묵적 단일 파일 폴백은 상태 전이 시 간헐적 장애 유발. 모든 클라이언트 동시 업데이트. | -| M-6 | PID 파일 JSON 포맷 전환 | port 포함 필요. 버전 마커로 구/신 포맷 구분. 자기 서술적. | -| M-7 | 뷰포트 WebSocket 브로드캐스트 제외 | 두 탭이 같은 파일 열면 zoom/pan 충돌. 뷰포트는 초기 로드에만 적용. | -| M-8 | 보안: validateFilePath + Host 검증 | 경로 기반 접근은 LFI 벡터. allowlist + `.vync` 확장자 + realpath + Host 헤더 검증. | - -## Architecture - -### Overview - -``` -vync open A.vync ─→ CLI ─→ POST /api/files ─→ Hub Server :3100 -vync open B.vync ─→ CLI ─→ POST /api/files ─→ ↓ - ┌──────────────┐ - │ FileRegistry │ - │ ┌─ A.vync ──┐│ - │ │ SyncService││ - │ │ FileWatcher││ - │ │ WS Clients ││ - │ └────────────┘│ - │ ┌─ B.vync ──┐│ - │ │ SyncService││ - │ │ FileWatcher││ - │ │ WS Clients ││ - │ └────────────┘│ - └──────────────┘ - ↓ -Browser Tab /?file=A ←─ WS(file=A) ──────────────────→ A.vync -Browser Tab /?file=B ←─ WS(file=B) ──────────────────→ B.vync -``` - -### FileRegistry (핵심 추상화) - -```typescript -class FileRegistry extends EventEmitter { - private files: Map; - }>; - private pendingUnregister: Set; // race condition 방지 - - static readonly MAX_FILES = 50; // VYNC_MAX_FILES env로 오버라이드 - - async register(filePath: string): Promise - // - validateFilePath() 통과 필수 - // - 이미 등록: 무시 (멱등) - // - Map에 동기적 슬롯 확보 → 비동기 초기화 (실패 시 롤백) - // - emit('registered', filePath) - - async unregister(filePath: string): Promise - // - pendingUnregister에 추가 (재등록 차단) - // - sync.drain() 대기 (writeQueue 완료) - // - watcher 닫기 - // - clients에 file-closed 메시지 전송 + 소켓 닫기 - // - Map에서 삭제 - // - pendingUnregister에서 제거 - // - emit('unregistered', filePath) - // - files.size === 0 이면 emit('empty') - - getSync(filePath: string): SyncService | undefined - listFiles(): string[] -} -``` - -### Security Layer - -```typescript -// tools/server/security.ts - -const ALLOWED_DIRS: Set = new Set(); - -function addAllowedDir(dir: string): void { - // realpath로 심링크 해석 후 등록 -} - -async function validateFilePath(rawPath: string): Promise { - const resolved = path.resolve(rawPath); - - // 1. .vync 확장자 필수 - if (!resolved.endsWith('.vync')) - throw new SecurityError('Only .vync files permitted'); - - // 2. realpath로 심링크 해석 - let real: string; - try { - real = await fs.realpath(resolved); - } catch { - // 파일 미존재 (create 케이스) — 부모 디렉토리만 해석 - const parentReal = await fs.realpath(path.dirname(resolved)); - real = path.join(parentReal, path.basename(resolved)); - } - - // 3. allowlist 확인 - const allowed = [...ALLOWED_DIRS].some( - dir => real.startsWith(dir + path.sep) || real === dir - ); - if (!allowed) - throw new SecurityError(`Path outside allowed directories: ${real}`); - - return real; // 정규화된 경로 반환 -} - -// Host 헤더 검증 미들웨어 (DNS rebinding 방어) -function hostGuard(port: number): RequestHandler { - const allowed = [`localhost:${port}`, `127.0.0.1:${port}`]; - return (req, res, next) => { - if (!req.headers.host || !allowed.includes(req.headers.host)) { - res.status(421).json({ error: 'Invalid Host header' }); - return; - } - next(); - }; -} -``` - -### API Design - -| Method | Path | Purpose | Auth | -|--------|------|---------|------| -| `GET` | `/api/health` | 서버 상태 + 버전 | — | -| `POST` | `/api/files` | 파일 등록 (body: `{filePath}`) | validateFilePath | -| `DELETE` | `/api/files?file=` | 파일 해제 | validateFilePath | -| `GET` | `/api/files` | 등록 파일 목록 | — | -| `GET` | `/api/sync?file=` | 파일 읽기 | validateFilePath | -| `PUT` | `/api/sync?file=` | 파일 쓰기 | validateFilePath | - -**`GET /api/health` 응답:** -```json -{ "version": 2, "mode": "hub", "fileCount": 3 } -``` - -**`POST /api/files` 멱등성:** -- 신규 파일: `201 Created` + `{ filePath, status: "registered" }` -- 이미 등록: `200 OK` + `{ filePath, status: "already_registered" }` -- 파일 미존재: `404` -- 잘못된 JSON: `422` -- 상한 초과: `429` - -**`?file=` 필수 (M-5):** -- `GET /api/sync` (`?file=` 없음) → `400` + `{ error: "file_required", files: [...] }` -- 모든 클라이언트를 동시 업데이트하므로 하위 호환 불필요 - -### WebSocket Protocol - -**연결:** -``` -ws://localhost:3100/ws?file= -``` - -**서버 측:** -- upgrade 시 `?file=` 파싱 → `validateFilePath()` → FileRegistry에서 조회 -- 미등록 파일: `{ type: 'error', code: 'FILE_NOT_FOUND' }` 전송 후 close(4404) -- 등록 파일: `clients` Set에 추가 -- **Origin 검사 강화**: `!origin || !allowedOrigins.includes(origin)` → reject - -**메시지 포맷 (변경):** -```typescript -interface WsMessage { - type: 'file-changed' | 'connected' | 'file-closed' | 'file-deleted' | 'error'; - filePath?: string; // 신규: 어떤 파일의 이벤트인지 - data?: VyncFile; - code?: string; // error 시 -} -``` - -**브로드캐스트:** 파일별 구독 모델. 같은 파일의 clients에게만 전송. - -**뷰포트 제외 (M-7):** -- `file-changed` 브로드캐스트 시 `data.viewport` 제외 -- 뷰포트는 `GET /api/sync?file=` 초기 로드에서만 수신 -- 각 탭의 뷰포트는 독립적 - -### startServer() 변경 - -```typescript -// 현재 -export async function startServer( - resolvedPath: string, - options?: { port?; mode?; staticDir?; openBrowser? } -) - -// 이후 -export async function startServer(options?: { - initialFile?: string; // 첫 파일 (없으면 빈 허브) - port?: number; // default 3100 - mode?: 'development' | 'production'; - staticDir?: string; -}) -// → 파일 없이 시작 가능 (데몬 먼저 시작, API로 파일 등록) -``` - -**서버 시작 순서:** -1. Express + Vite 미들웨어 초기화 -2. FileRegistry 생성 -3. hostGuard 미들웨어 등록 -4. API 라우트 등록 -5. WebSocket 핸들러 등록 -6. `server.listen(port)` → 건강 검사 가능 -7. (옵션) `initialFile` 있으면 `registry.register(initialFile)` - -## CLI Changes - -### `vync open` — 2-state - -``` -server-down: - 1. startServer (파일 없이, 또는 initialFile로) - 2. poll GET /api/health until 200 - 3. POST /api/files { filePath: resolved } - 4. open browser: localhost:PORT/?file= - -server-up: - 1. POST /api/files { filePath: resolved } - 2. open browser: localhost:PORT/?file= -``` - -**서버 버전 감지:** -- `GET /api/health` → 200 + `version: 2` → 허브 모드 -- `GET /api/health` → 404 → 구 서버 → `vyncStop()` 후 새 서버 시작 - -### `vync close ` — 신규 - -``` -1. resolveVyncPath(file) -2. DELETE /api/files?file= -3. 서버가 등록 0개면 자동 종료 - → "[vync] Last file closed. Server stopped." -4. --keep-server 옵션: 파일만 해제, 서버 유지 -``` - -### `vync stop` — 기존 유지 - -``` -[vync] Warning: 3 files currently registered -[vync] Server stopped (PID 12345) -``` - -### PID 파일 — JSON (M-6) - -```json -{ "version": 2, "pid": 12345, "mode": "daemon", "port": 3100 } -``` - -**마이그레이션:** `readServerInfo()`가 JSON 파싱 실패 시 구 3줄 포맷으로 폴백: -```typescript -function readServerInfo(): ServerInfo | null { - const raw = fs.readFileSync(PID_PATH, 'utf-8'); - try { - return JSON.parse(raw); // 신 포맷 - } catch { - const lines = raw.trim().split('\n'); - if (lines.length >= 2) { - return { version: 1, pid: Number(lines[0]), mode: lines[1], port: 3100 }; - } - return null; // stale - } -} -``` - -## Frontend Changes (Stage 1) - -### FileBoard 컴포넌트 분리 (Stage 2 대비) - -``` -App - └─ FileBoard (filePath prop) - ├─ value state - ├─ WebSocket connection (file-scoped) - ├─ API calls (file-scoped) - ├─ localforage (file-scoped key) - └─ Drawnix Wrapper -``` - -Stage 1에서는 `App`이 `FileBoard`를 하나만 렌더링. Stage 2에서 탭 바 + 여러 `FileBoard` 인스턴스. - -### URL 파라미터 - -```typescript -const filePath = new URLSearchParams(window.location.search).get('file'); -if (!filePath) { - // 에러 UI: "No file specified. Use `vync open ` to start." - return; -} -``` - -### API 호출 - -```typescript -// 초기 로드 -const res = await fetch(`/api/sync?file=${encodeURIComponent(filePath)}`); -if (res.status === 404) { - // 서버 재시작 후 → 프론트엔드가 재등록 - await fetch('/api/files', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath }), - }); - // 재시도 -} -``` - -### WebSocket - -```typescript -const ws = new WebSocket( - `${protocol}//${host}/ws?file=${encodeURIComponent(filePath)}` -); -``` - -### LocalStorage 마이그레이션 - -```typescript -const storageKey = `vync_board_${filePath}`; - -// 일회성 마이그레이션 -const legacy = await localforage.getItem('main_board_content'); -if (legacy && !await localforage.getItem(storageKey)) { - await localforage.setItem(storageKey, legacy); - // 롤백 대비: 구 키 삭제하지 않음 -} -``` - -### 뷰포트 처리 - -- 초기 로드: `GET /api/sync?file=` 응답의 viewport 적용 -- WebSocket `file-changed`: **viewport 무시**, elements만 업데이트 -- 각 탭의 viewport는 독립적 (state로 유지) - -## Electron Changes (Stage 1) - -```typescript -async function openFile(filePath: string) { - if (serverHandle) { - // 서버 실행 중 → 파일 등록만 (재시작 아님) - await fetch(`${serverHandle.url}/api/files`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath }), - }); - } else { - // 서버 시작 - const mod = await import('../server/server.js'); - serverHandle = await mod.startServer({ - initialFile: filePath, - port: 3100, - mode: isDev ? 'development' : 'production', - }); - } - - const url = `${serverHandle.url}/?file=${encodeURIComponent(filePath)}`; - if (mainWindow) { - // beforeunload로 pending debounce flush 후 URL 변경 - mainWindow.loadURL(url); - } else { - createWindow(url); - } -} -``` - -Stage 1: 단일 윈도우, URL 변경으로 파일 전환. -Stage 2: 멀티 윈도우 또는 탭 UI. - -## Plugin Changes - -### Sub-agent (vync-translator) - -**변경 없음.** `node "$VYNC_HOME/bin/vync.js" open ` 호출은 CLI 내부에서 파일 등록으로 전환됨. - -### /vync 커맨드 - -`close` 서브커맨드 문서 추가: -``` -- close [file] — 파일 서버 등록 해제 (서버는 유지) -``` - -### Hooks - -**SessionEnd 변경 (M-7 연관):** -```bash -# 기존: kill server -# 수정: 등록 파일 전체 해제 (서버가 파일 0개면 자동 종료) -curl -s -X DELETE "http://localhost:3100/api/files?all=true" 2>/dev/null || true -[ -f "$HOME/.vync/server.pid" ] && { pid=$(jq -r '.pid' "$HOME/.vync/server.pid" 2>/dev/null || head -1 "$HOME/.vync/server.pid"); kill "$pid" 2>/dev/null; rm -f "$HOME/.vync/server.pid"; }; exit 0 -``` - -## Race Condition Handling - -| Race | Mechanism | Resolution | -|------|-----------|------------| -| 동시 register (같은 파일) | Map에 동기적 슬롯 확보 | 두 번째 호출은 즉시 return (멱등) | -| 동시 register (다른 파일) | 독립적 — 충돌 없음 | 병렬 처리 | -| 동시 `vync open` (서버 미시작) | 둘 다 서버 시작 시도 | EADDRINUSE → PID 재확인 → POST /api/files | -| unregister 중 write 진행 | `sync.drain()` 대기 | writeQueue 완료 후 watcher 닫기 | -| unregister 중 register (같은 파일) | `pendingUnregister` Set | unregister 완료까지 재등록 차단 | - -## File Lifecycle Events - -| Event | Source | Server Action | Client Notification | -|-------|--------|---------------|---------------------| -| 파일 등록 | `POST /api/files` | SyncService + Watcher 생성 | — | -| 파일 해제 | `DELETE /api/files` | drain → watcher 닫기 → cleanup | `{ type: 'file-closed' }` | -| 파일 삭제 (디스크) | chokidar `unlink` | 클라이언트 알림, SyncService 유지 (PUT으로 재생성 가능) | `{ type: 'file-deleted' }` | -| 파일 재등장 | chokidar `add` | 재로드 + 브로드캐스트 | `{ type: 'file-changed' }` | -| 유휴 30분 (WS 0개) | 타이머 | 자동 unregister + watcher 정리 | — | -| 서버 종료 | SIGTERM/`vync stop` | 모든 SyncService drain → watcher 닫기 → WS 닫기 | 연결 끊김 | - -## Backward Compatibility - -| 항목 | 전략 | -|------|------| -| PID 파일 (구 3줄) | `readServerInfo()` JSON 우선 → 실패 시 3줄 폴백 파싱 | -| localStorage (`main_board_content`) | 새 키로 복사 (이동 아님). 구 키 유지 (롤백 대비) | -| 구 서버 실행 중 + 신 CLI | `GET /api/health` → 404면 구 서버 → stop + 새 서버 시작 | -| 신 서버 + 구 프론트엔드(캐시) | `?file=` 없음 → 400 + 파일 목록. HMR이 새 코드 로드 유도 | -| Sub-agent | 변경 없음. `vync open` CLI 인터페이스 동일 | -| Hooks | SessionEnd 변경 필요. install.sh 재실행으로 적용 | - -**롤백 절차:** -```bash -vync stop # 현재 코드로 서버 정지 -git checkout # 코드 복원 -npm install # 의존성 -bash .claude-plugin/install.sh # 훅 재설치 -``` - -## Stage 2 Preview (멀티 탭 UI) - -> 1단계 완료 후 이 섹션을 재검토하고 세부 계획을 작성한다. - -**예상 범위:** -- 프론트엔드: 탭 바 UI 컴포넌트 -- `App` → 여러 `FileBoard` 인스턴스 관리 -- `GET /api/files`로 등록 파일 목록 표시 -- 탭 전환: 기존 WS 유지 + 새 WS 연결 (파일별 독립 연결) -- Electron: 멀티 윈도우 지원 (BrowserWindow 여러 개) -- `remoteUpdateUntilRef` → `Map` (파일별 echo 방지) - -**1단계에서 미리 준비한 것들:** -- `FileBoard` 컴포넌트 분리 -- `WsMessage.filePath` 필드 -- 파일별 localStorage 키 -- `GET /api/files` 엔드포인트 - -## Testing Strategy - -### 유닛 테스트 -- `FileRegistry`: register/unregister/idempotent/max-limit/race -- `validateFilePath`: allowlist/extension/.realpath/symlink/traversal -- `readServerInfo`: JSON/legacy 3-line/stale/corrupt -- WsMessage 라우팅: file-scoped broadcast - -### 통합 테스트 -- 서버 시작 (파일 없이) → 파일 등록 → 파일 읽기/쓰기 → 파일 해제 -- 두 파일 동시 등록 → 각각 독립 편집 → echo 방지 확인 -- WebSocket: 파일 A 변경 → 파일 B 구독자는 수신 안 함 -- 뷰포트: WS 업데이트에서 뷰포트 변경 안 됨 - -### E2E 테스트 -- `vync open A` → `vync open B` → 두 브라우저 탭 모두 동작 -- 서버 재시작 → 브라우저 새로고침 → 프론트엔드가 POST로 재등록 → 정상 동작 -- `vync close A` → A 탭에 "file closed" 알림 → B 탭 영향 없음 -- 보안: `/api/sync?file=/etc/passwd` → 403 diff --git a/docs/archive/2026-03-09-multi-file-hub-implementation.md b/docs/archive/2026-03-09-multi-file-hub-implementation.md deleted file mode 100644 index 7539eee..0000000 --- a/docs/archive/2026-03-09-multi-file-hub-implementation.md +++ /dev/null @@ -1,1976 +0,0 @@ -# Multi-File Hub Server — Stage 1 Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 단일 파일 서버를 허브 아키텍처로 전환하여 여러 .vync 파일을 동시에 열고 편집할 수 있게 한다. - -**Architecture:** 하나의 Express 서버(:3100)가 FileRegistry를 통해 여러 파일을 관리. 각 파일은 독립적인 SyncService + FileWatcher + WebSocket 클라이언트 집합을 가짐. CLI `vync open`은 서버 재시작 대신 파일 등록, `vync close`로 해제. - -**Tech Stack:** TypeScript, Express, ws (WebSocket), chokidar, Node.js crypto (SHA-256) - -**Design doc:** `docs/plans/2026-03-09-multi-file-hub-design.md` - ---- - -## Task 1: Shared Types — WsMessage 확장 - -**Files:** -- Modify: `packages/shared/src/types.ts` -- Test: `packages/shared/src/__tests__/types.test.ts` (신규) - -**Step 1: Write the failing test** - -```typescript -// packages/shared/src/__tests__/types.test.ts -import { describe, it, expect } from 'vitest'; -import type { WsMessage } from '../types.js'; - -describe('WsMessage', () => { - it('should accept filePath field', () => { - const msg: WsMessage = { - type: 'file-changed', - filePath: '/path/to/file.vync', - data: { version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements: [] }, - }; - expect(msg.filePath).toBe('/path/to/file.vync'); - }); - - it('should accept new message types', () => { - const closed: WsMessage = { type: 'file-closed', filePath: '/a.vync' }; - const deleted: WsMessage = { type: 'file-deleted', filePath: '/a.vync' }; - const error: WsMessage = { type: 'error', code: 'FILE_NOT_FOUND' }; - expect(closed.type).toBe('file-closed'); - expect(deleted.type).toBe('file-deleted'); - expect(error.code).toBe('FILE_NOT_FOUND'); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run packages/shared/src/__tests__/types.test.ts` -Expected: FAIL — `filePath`, `code` not in type, `'file-closed'` etc not assignable. - -**Step 3: Update types** - -```typescript -// packages/shared/src/types.ts -export interface VyncViewport { - zoom: number; - x: number; - y: number; -} - -export interface VyncFile { - version: number; - viewport: VyncViewport; - elements: T[]; -} - -export interface WsMessage { - type: 'file-changed' | 'connected' | 'file-closed' | 'file-deleted' | 'error'; - filePath?: string; - data?: VyncFile; - code?: string; -} -``` - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run packages/shared/src/__tests__/types.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add packages/shared/src/types.ts packages/shared/src/__tests__/types.test.ts -git commit -m "feat(shared): extend WsMessage with filePath, file-closed, file-deleted, error types" -``` - ---- - -## Task 2: Security Layer — validateFilePath + hostGuard - -**Files:** -- Create: `tools/server/security.ts` -- Test: `tools/server/__tests__/security.test.ts` (신규) - -**Step 1: Write the failing test** - -```typescript -// tools/server/__tests__/security.test.ts -import { describe, it, expect, beforeEach } from 'vitest'; -import path from 'node:path'; -import os from 'node:os'; -import fs from 'node:fs/promises'; -import { validateFilePath, addAllowedDir, clearAllowedDirs } from '../security.js'; - -describe('validateFilePath', () => { - const tmpDir = path.join(os.tmpdir(), 'vync-security-test'); - - beforeEach(async () => { - clearAllowedDirs(); - await fs.mkdir(tmpDir, { recursive: true }); - addAllowedDir(tmpDir); - }); - - it('rejects non-.vync extension', async () => { - await expect(validateFilePath('/etc/passwd')).rejects.toThrow('Only .vync files permitted'); - }); - - it('rejects path outside allowed dirs', async () => { - await expect(validateFilePath('/tmp/evil.vync')).rejects.toThrow('outside allowed directories'); - }); - - it('accepts valid .vync path inside allowed dir', async () => { - const filePath = path.join(tmpDir, 'test.vync'); - await fs.writeFile(filePath, '{}'); - const result = await validateFilePath(filePath); - expect(result).toBe(filePath); - }); - - it('resolves .. segments before allowlist check', async () => { - await expect( - validateFilePath(path.join(tmpDir, '..', '..', 'etc', 'passwd.vync')) - ).rejects.toThrow('outside allowed directories'); - }); - - it('handles non-existent file (create case) via parent dir', async () => { - const filePath = path.join(tmpDir, 'new.vync'); - const result = await validateFilePath(filePath); - expect(result).toBe(filePath); - }); - - it('rejects when max files reached', async () => { - // Tested in FileRegistry, not here — validateFilePath does not check count - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run tools/server/__tests__/security.test.ts` -Expected: FAIL — module not found. - -**Step 3: Implement security.ts** - -```typescript -// tools/server/security.ts -import path from 'node:path'; -import fs from 'node:fs/promises'; - -const allowedDirs: Set = new Set(); - -export function addAllowedDir(dir: string): void { - allowedDirs.add(path.resolve(dir)); -} - -export function clearAllowedDirs(): void { - allowedDirs.clear(); -} - -export function getAllowedDirs(): ReadonlySet { - return allowedDirs; -} - -export async function validateFilePath(rawPath: string): Promise { - const resolved = path.resolve(rawPath); - - if (!resolved.endsWith('.vync')) { - throw new Error('Only .vync files permitted'); - } - - // Resolve symlinks: use realpath for existing files, parent realpath for new files - let real: string; - try { - real = await fs.realpath(resolved); - } catch { - try { - const parentReal = await fs.realpath(path.dirname(resolved)); - real = path.join(parentReal, path.basename(resolved)); - } catch { - throw new Error(`Parent directory does not exist: ${path.dirname(resolved)}`); - } - } - - const allowed = [...allowedDirs].some( - (dir) => real.startsWith(dir + path.sep) || real === path.resolve(dir) - ); - if (!allowed) { - throw new Error(`Path outside allowed directories: ${real}`); - } - - return real; -} - -export function createHostGuard(port: number) { - const allowed = [`localhost:${port}`, `127.0.0.1:${port}`]; - return (req: any, res: any, next: any) => { - const host = req.headers.host; - if (!host || !allowed.includes(host)) { - res.status(421).json({ error: 'Invalid Host header' }); - return; - } - next(); - }; -} -``` - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run tools/server/__tests__/security.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add tools/server/security.ts tools/server/__tests__/security.test.ts -git commit -m "feat(server): add validateFilePath security layer with allowlist and host guard" -``` - ---- - -## Task 3: SyncService — drain() 메서드 추가 - -**Files:** -- Modify: `tools/server/sync-service.ts:9,48-49,94` -- Test: `tools/server/__tests__/sync-drain.test.ts` (신규) - -**Step 1: Write the failing test** - -```typescript -// tools/server/__tests__/sync-drain.test.ts -import { describe, it, expect } from 'vitest'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { createSyncService } from '../sync-service.js'; - -describe('SyncService.drain', () => { - it('resolves immediately when no writes pending', async () => { - const tmpFile = path.join(os.tmpdir(), `drain-test-${Date.now()}.vync`); - await fs.writeFile(tmpFile, JSON.stringify({ version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements: [] })); - const sync = createSyncService(tmpFile); - await sync.init(); - await sync.drain(); // should not hang - await fs.unlink(tmpFile).catch(() => {}); - }); - - it('waits for in-flight write to complete', async () => { - const tmpFile = path.join(os.tmpdir(), `drain-test2-${Date.now()}.vync`); - const data = { version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements: [{ id: 'aaaaa' }] }; - await fs.writeFile(tmpFile, JSON.stringify({ version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements: [] })); - const sync = createSyncService(tmpFile); - await sync.init(); - // Fire write without awaiting - const writePromise = sync.writeFile(data); - await sync.drain(); - // After drain, write must be complete - const content = JSON.parse(await fs.readFile(tmpFile, 'utf-8')); - expect(content.elements).toHaveLength(1); - await writePromise; - await fs.unlink(tmpFile).catch(() => {}); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run tools/server/__tests__/sync-drain.test.ts` -Expected: FAIL — `sync.drain is not a function` - -**Step 3: Add drain() + expose filePath** - -Modify `tools/server/sync-service.ts`: - -At line 94, change the return statement from: -```typescript - return { init, writeFile, handleFileChange, readFile }; -``` -to: -```typescript - async function drain(): Promise { - await writeQueue; - } - - return { init, writeFile, handleFileChange, readFile, drain, filePath }; -``` - -Also update the type export at line 97 — no change needed (ReturnType auto-updates). - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run tools/server/__tests__/sync-drain.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add tools/server/sync-service.ts tools/server/__tests__/sync-drain.test.ts -git commit -m "feat(sync): add drain() method for graceful write queue completion" -``` - ---- - -## Task 4: FileWatcher — unlink 이벤트 지원 - -**Files:** -- Modify: `tools/server/file-watcher.ts:4-6,16-23` -- Test: `tools/server/__tests__/file-watcher-unlink.test.ts` (신규) - -**Step 1: Write the failing test** - -```typescript -// tools/server/__tests__/file-watcher-unlink.test.ts -import { describe, it, expect } from 'vitest'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { createFileWatcher } from '../file-watcher.js'; - -describe('FileWatcher unlink', () => { - it('calls onDelete when file is removed', async () => { - const tmpFile = path.join(os.tmpdir(), `watcher-unlink-${Date.now()}.vync`); - await fs.writeFile(tmpFile, '{}'); - - let deleteCalled = false; - const watcher = createFileWatcher(tmpFile, { - onChange: () => {}, - onDelete: () => { deleteCalled = true; }, - }); - - // Wait for watcher to be ready - await new Promise((resolve) => watcher.on('ready', resolve)); - - await fs.unlink(tmpFile); - - // Wait for event propagation - await new Promise((r) => setTimeout(r, 500)); - expect(deleteCalled).toBe(true); - await watcher.close(); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run tools/server/__tests__/file-watcher-unlink.test.ts` -Expected: FAIL — `createFileWatcher` doesn't accept object callbacks. - -**Step 3: Update file-watcher.ts** - -```typescript -// tools/server/file-watcher.ts -import chokidar from 'chokidar'; -import fs from 'node:fs/promises'; - -export interface FileWatcherCallbacks { - onChange: (content: string) => void; - onDelete?: () => void; -} - -export function createFileWatcher( - filePath: string, - callbacks: FileWatcherCallbacks | ((content: string) => void) -) { - const { onChange, onDelete } = - typeof callbacks === 'function' - ? { onChange: callbacks, onDelete: undefined } - : callbacks; - - const watcher = chokidar.watch(filePath, { - persistent: true, - awaitWriteFinish: { - stabilityThreshold: 300, - pollInterval: 100, - }, - }); - - watcher.on('change', async () => { - try { - const content = await fs.readFile(filePath, 'utf-8'); - onChange(content); - } catch (err) { - console.error('[vync] Error reading changed file:', err); - } - }); - - if (onDelete) { - watcher.on('unlink', () => { - console.log(`[vync] File deleted: ${filePath}`); - onDelete(); - }); - } - - return watcher; -} -``` - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run tools/server/__tests__/file-watcher-unlink.test.ts` -Expected: PASS - -**Step 5: Verify existing server still compiles** (file-watcher backward compat — old call with function still works) - -Run: `npx tsc --noEmit -p tools/server/tsconfig.json 2>&1 || npx tsc --noEmit` -Expected: No new errors (function callback still accepted via union type). - -**Step 6: Commit** - -```bash -git add tools/server/file-watcher.ts tools/server/__tests__/file-watcher-unlink.test.ts -git commit -m "feat(watcher): support onDelete callback for file unlink events" -``` - ---- - -## Task 5: FileRegistry — 핵심 추상화 - -**Files:** -- Create: `tools/server/file-registry.ts` -- Test: `tools/server/__tests__/file-registry.test.ts` (신규) - -**Step 1: Write the failing test** - -```typescript -// tools/server/__tests__/file-registry.test.ts -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { FileRegistry } from '../file-registry.js'; -import { addAllowedDir, clearAllowedDirs } from '../security.js'; - -describe('FileRegistry', () => { - const tmpDir = path.join(os.tmpdir(), `vync-registry-test-${Date.now()}`); - let registry: FileRegistry; - const makeFile = async (name: string) => { - const p = path.join(tmpDir, name); - await fs.writeFile(p, JSON.stringify({ version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements: [] })); - return p; - }; - - beforeEach(async () => { - await fs.mkdir(tmpDir, { recursive: true }); - clearAllowedDirs(); - addAllowedDir(tmpDir); - registry = new FileRegistry(); - }); - - afterEach(async () => { - await registry.shutdown(); - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - it('registers a file and lists it', async () => { - const p = await makeFile('a.vync'); - await registry.register(p); - expect(registry.listFiles()).toContain(p); - }); - - it('register is idempotent', async () => { - const p = await makeFile('b.vync'); - await registry.register(p); - await registry.register(p); // no error - expect(registry.listFiles()).toHaveLength(1); - }); - - it('unregisters a file', async () => { - const p = await makeFile('c.vync'); - await registry.register(p); - await registry.unregister(p); - expect(registry.listFiles()).not.toContain(p); - }); - - it('getSync returns SyncService for registered file', async () => { - const p = await makeFile('d.vync'); - await registry.register(p); - const sync = registry.getSync(p); - expect(sync).toBeDefined(); - expect(sync!.filePath).toBe(p); - }); - - it('getSync returns undefined for unregistered file', () => { - expect(registry.getSync('/nonexistent.vync')).toBeUndefined(); - }); - - it('enforces max file limit', async () => { - const oldMax = FileRegistry.MAX_FILES; - FileRegistry.MAX_FILES = 2; - const p1 = await makeFile('e1.vync'); - const p2 = await makeFile('e2.vync'); - const p3 = await makeFile('e3.vync'); - await registry.register(p1); - await registry.register(p2); - await expect(registry.register(p3)).rejects.toThrow('Maximum'); - FileRegistry.MAX_FILES = oldMax; - }); - - it('emits registered/unregistered events', async () => { - const events: string[] = []; - registry.on('registered', (fp: string) => events.push(`reg:${path.basename(fp)}`)); - registry.on('unregistered', (fp: string) => events.push(`unreg:${path.basename(fp)}`)); - const p = await makeFile('f.vync'); - await registry.register(p); - await registry.unregister(p); - expect(events).toEqual([`reg:f.vync`, `unreg:f.vync`]); - }); - - it('emits empty event when last file unregistered', async () => { - let emptyCalled = false; - registry.on('empty', () => { emptyCalled = true; }); - const p = await makeFile('g.vync'); - await registry.register(p); - await registry.unregister(p); - expect(emptyCalled).toBe(true); - }); - - it('blocks register during pending unregister', async () => { - const p = await makeFile('h.vync'); - await registry.register(p); - const unregPromise = registry.unregister(p); - // While unregister is in progress, register should wait - await unregPromise; - // Now register should succeed - await registry.register(p); - expect(registry.listFiles()).toContain(p); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run tools/server/__tests__/file-registry.test.ts` -Expected: FAIL — module not found. - -**Step 3: Implement FileRegistry** - -```typescript -// tools/server/file-registry.ts -import { EventEmitter } from 'node:events'; -import { createSyncService, type SyncService } from './sync-service.js'; -import { createFileWatcher } from './file-watcher.js'; -import { validateFilePath } from './security.js'; -import type { FSWatcher } from 'chokidar'; -import type { WebSocket } from 'ws'; -import type { WsMessage } from '@vync/shared'; - -interface FileEntry { - sync: SyncService; - watcher: FSWatcher; - clients: Set; - idleTimer?: ReturnType; -} - -const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes - -export class FileRegistry extends EventEmitter { - static MAX_FILES = Number(process.env.VYNC_MAX_FILES) || 50; - private files = new Map(); - private pendingUnregister = new Set(); - - async register(filePath: string): Promise { - const validated = await validateFilePath(filePath); - - // Wait if unregister is in progress for this file - while (this.pendingUnregister.has(validated)) { - await new Promise((r) => setTimeout(r, 50)); - } - - // Idempotent - if (this.files.has(validated)) { - this.resetIdleTimer(validated); - return; - } - - if (this.files.size >= FileRegistry.MAX_FILES) { - throw new Error(`Maximum number of tracked files (${FileRegistry.MAX_FILES}) reached`); - } - - // Claim slot synchronously - const entry: FileEntry = { - sync: null as any, - watcher: null as any, - clients: new Set(), - }; - this.files.set(validated, entry); - - try { - entry.sync = createSyncService(validated); - await entry.sync.init(); - entry.watcher = createFileWatcher(validated, { - onChange: (content) => { - const data = entry.sync.handleFileChange(content); - if (data) { - this.broadcastToFile(validated, { type: 'file-changed', filePath: validated, data }); - } - }, - onDelete: () => { - this.broadcastToFile(validated, { type: 'file-deleted', filePath: validated }); - }, - }); - } catch (err) { - this.files.delete(validated); - throw err; - } - - this.emit('registered', validated); - } - - async unregister(filePath: string): Promise { - const validated = await validateFilePath(filePath); - const entry = this.files.get(validated); - if (!entry) return; - - this.pendingUnregister.add(validated); - - try { - // Notify clients - this.broadcastToFile(validated, { type: 'file-closed', filePath: validated }); - - // Drain write queue - await entry.sync.drain(); - - // Close watcher - await entry.watcher.close(); - - // Clear idle timer - if (entry.idleTimer) clearTimeout(entry.idleTimer); - - // Close client connections - for (const ws of entry.clients) { - ws.close(4000, 'File unregistered'); - } - - this.files.delete(validated); - } finally { - this.pendingUnregister.delete(validated); - } - - this.emit('unregistered', validated); - if (this.files.size === 0) { - this.emit('empty'); - } - } - - getSync(filePath: string): SyncService | undefined { - return this.files.get(filePath)?.sync; - } - - getEntry(filePath: string): FileEntry | undefined { - return this.files.get(filePath); - } - - listFiles(): string[] { - return [...this.files.keys()]; - } - - addClient(filePath: string, ws: WebSocket): void { - const entry = this.files.get(filePath); - if (!entry) return; - entry.clients.add(ws); - this.resetIdleTimer(filePath); - } - - removeClient(filePath: string, ws: WebSocket): void { - const entry = this.files.get(filePath); - if (!entry) return; - entry.clients.delete(ws); - if (entry.clients.size === 0) { - this.startIdleTimer(filePath); - } - } - - broadcastToFile(filePath: string, message: WsMessage): void { - const entry = this.files.get(filePath); - if (!entry) return; - const data = JSON.stringify(message); - for (const client of entry.clients) { - if (client.readyState === 1 /* WebSocket.OPEN */) { - client.send(data); - } - } - } - - async shutdown(): Promise { - const files = [...this.files.keys()]; - for (const fp of files) { - await this.unregister(fp).catch(() => {}); - } - } - - private startIdleTimer(filePath: string): void { - const entry = this.files.get(filePath); - if (!entry) return; - if (entry.idleTimer) clearTimeout(entry.idleTimer); - entry.idleTimer = setTimeout(() => { - console.log(`[vync] Idle timeout: unregistering ${filePath}`); - this.unregister(filePath).catch(() => {}); - }, IDLE_TIMEOUT_MS); - } - - private resetIdleTimer(filePath: string): void { - const entry = this.files.get(filePath); - if (!entry) return; - if (entry.idleTimer) { - clearTimeout(entry.idleTimer); - entry.idleTimer = undefined; - } - } -} -``` - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run tools/server/__tests__/file-registry.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add tools/server/file-registry.ts tools/server/__tests__/file-registry.test.ts -git commit -m "feat(server): add FileRegistry with per-file SyncService, watcher, and WS client management" -``` - ---- - -## Task 6: WebSocket Handler — 파일별 라우팅 - -**Files:** -- Modify: `tools/server/ws-handler.ts` (전체 재작성) -- Test: `tools/server/__tests__/ws-handler.test.ts` (신규) - -**Step 1: Write the failing test** - -```typescript -// tools/server/__tests__/ws-handler.test.ts -import { describe, it, expect } from 'vitest'; -import http from 'node:http'; -import { WebSocket } from 'ws'; -import { createWsServer } from '../ws-handler.js'; -import { FileRegistry } from '../file-registry.js'; -import { addAllowedDir, clearAllowedDirs } from '../security.js'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; - -describe('WsHandler file routing', () => { - it('rejects connection without ?file param', async () => { - const server = http.createServer(); - const registry = new FileRegistry(); - createWsServer(server, 0, registry); - - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const port = (server.address() as any).port; - - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); - const msg = await new Promise((resolve) => { - ws.on('message', (data) => resolve(JSON.parse(data.toString()))); - ws.on('close', () => resolve({ type: 'closed' })); - }); - expect(msg.type).toBe('error'); - - ws.close(); - await registry.shutdown(); - server.close(); - }); - - it('accepts connection with valid ?file param', async () => { - const tmpDir = path.join(os.tmpdir(), `ws-test-${Date.now()}`); - await fs.mkdir(tmpDir, { recursive: true }); - const filePath = path.join(tmpDir, 'test.vync'); - await fs.writeFile(filePath, JSON.stringify({ version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements: [] })); - - clearAllowedDirs(); - addAllowedDir(tmpDir); - - const server = http.createServer(); - const registry = new FileRegistry(); - await registry.register(filePath); - createWsServer(server, 0, registry); - - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const port = (server.address() as any).port; - - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?file=${encodeURIComponent(filePath)}`); - const msg = await new Promise((resolve) => { - ws.on('message', (data) => resolve(JSON.parse(data.toString()))); - }); - expect(msg.type).toBe('connected'); - expect(msg.filePath).toBe(filePath); - - ws.close(); - await registry.shutdown(); - server.close(); - await fs.rm(tmpDir, { recursive: true, force: true }); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run tools/server/__tests__/ws-handler.test.ts` -Expected: FAIL — `createWsServer` signature mismatch. - -**Step 3: Rewrite ws-handler.ts** - -```typescript -// tools/server/ws-handler.ts -import { WebSocketServer, WebSocket } from 'ws'; -import type { Server } from 'node:http'; -import type { WsMessage } from '@vync/shared'; -import type { FileRegistry } from './file-registry.js'; - -export function createWsServer(server: Server, port: number, registry: FileRegistry) { - const wss = new WebSocketServer({ noServer: true }); - const clientFiles = new Map(); - - const allowedOrigins = [ - `http://localhost:${port}`, - `http://127.0.0.1:${port}`, - ]; - - server.on('upgrade', (request, socket, head) => { - const url = new URL(request.url!, `http://${request.headers.host}`); - if (url.pathname !== '/ws') return; - - // Strict origin check: reject absent origin - const origin = request.headers.origin; - if (port > 0 && (!origin || !allowedOrigins.includes(origin))) { - socket.write('HTTP/1.1 403 Forbidden\r\n\r\n'); - socket.destroy(); - return; - } - - const filePath = url.searchParams.get('file'); - - wss.handleUpgrade(request, socket, head, (ws) => { - wss.emit('connection', ws, request, filePath); - }); - }); - - wss.on('connection', (ws: WebSocket, _request: any, filePath: string | null) => { - if (!filePath) { - ws.send(JSON.stringify({ type: 'error', code: 'FILE_REQUIRED' } satisfies WsMessage)); - ws.close(4400, 'file parameter required'); - return; - } - - // Check if file is registered - if (!registry.getSync(filePath)) { - ws.send(JSON.stringify({ type: 'error', code: 'FILE_NOT_FOUND' } satisfies WsMessage)); - ws.close(4404, 'File not registered'); - return; - } - - clientFiles.set(ws, filePath); - registry.addClient(filePath, ws); - - console.log(`[vync] WS client connected: ${filePath}`); - ws.send(JSON.stringify({ type: 'connected', filePath } satisfies WsMessage)); - - ws.on('close', () => { - const fp = clientFiles.get(ws); - if (fp) { - registry.removeClient(fp, ws); - clientFiles.delete(ws); - } - console.log('[vync] WS client disconnected'); - }); - }); - - return { - close() { - wss.clients.forEach((client) => client.terminate()); - wss.close(); - }, - }; -} -``` - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run tools/server/__tests__/ws-handler.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add tools/server/ws-handler.ts tools/server/__tests__/ws-handler.test.ts -git commit -m "feat(ws): file-scoped WebSocket routing with strict origin check" -``` - ---- - -## Task 7: Server — Hub 모드 리팩토링 - -**Files:** -- Modify: `tools/server/server.ts` (대규모 수정) -- Test: `tools/server/__tests__/server-hub.test.ts` (신규) - -**Step 1: Write the failing test** - -```typescript -// tools/server/__tests__/server-hub.test.ts -import { describe, it, expect, afterEach } from 'vitest'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { addAllowedDir, clearAllowedDirs } from '../security.js'; - -describe('Hub Server', () => { - const tmpDir = path.join(os.tmpdir(), `vync-hub-test-${Date.now()}`); - let shutdownFn: (() => Promise) | null = null; - - afterEach(async () => { - if (shutdownFn) { await shutdownFn(); shutdownFn = null; } - clearAllowedDirs(); - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - }); - - it('starts without initial file and responds to /api/health', async () => { - const { startServer } = await import('../server.js'); - const port = 3200 + Math.floor(Math.random() * 100); - const result = await startServer({ port, mode: 'production', staticDir: undefined }); - shutdownFn = result.shutdown; - - const res = await fetch(`http://localhost:${port}/api/health`); - const body = await res.json(); - expect(body.version).toBe(2); - expect(body.mode).toBe('hub'); - expect(body.fileCount).toBe(0); - }); - - it('registers a file via POST /api/files', async () => { - await fs.mkdir(tmpDir, { recursive: true }); - const filePath = path.join(tmpDir, 'a.vync'); - await fs.writeFile(filePath, JSON.stringify({ version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements: [] })); - addAllowedDir(tmpDir); - - const { startServer } = await import('../server.js'); - const port = 3200 + Math.floor(Math.random() * 100); - const result = await startServer({ port, mode: 'production', staticDir: undefined }); - shutdownFn = result.shutdown; - - const res = await fetch(`http://localhost:${port}/api/files`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath }), - }); - expect(res.status).toBe(201); - - // Verify file is accessible - const syncRes = await fetch(`http://localhost:${port}/api/sync?file=${encodeURIComponent(filePath)}`); - expect(syncRes.ok).toBe(true); - const data = await syncRes.json(); - expect(data.version).toBe(1); - }); - - it('returns 400 when ?file= missing on /api/sync', async () => { - const { startServer } = await import('../server.js'); - const port = 3200 + Math.floor(Math.random() * 100); - const result = await startServer({ port, mode: 'production', staticDir: undefined }); - shutdownFn = result.shutdown; - - const res = await fetch(`http://localhost:${port}/api/sync`); - expect(res.status).toBe(400); - }); - - it('returns 403 for path outside allowed dirs', async () => { - const { startServer } = await import('../server.js'); - const port = 3200 + Math.floor(Math.random() * 100); - const result = await startServer({ port, mode: 'production', staticDir: undefined }); - shutdownFn = result.shutdown; - - const res = await fetch(`http://localhost:${port}/api/files`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath: '/etc/passwd' }), - }); - expect(res.status).toBe(403); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run tools/server/__tests__/server-hub.test.ts` -Expected: FAIL — `startServer` signature mismatch. - -**Step 3: Rewrite server.ts** - -```typescript -// tools/server/server.ts -import http from 'node:http'; -import path from 'node:path'; -import express from 'express'; -import { createWsServer } from './ws-handler.js'; -import { FileRegistry } from './file-registry.js'; -import { addAllowedDir, createHostGuard, validateFilePath } from './security.js'; -import type { VyncFile } from '@vync/shared'; - -const DEFAULT_PORT = 3100; - -export async function startServer( - options: { - initialFile?: string; - port?: number; - mode?: 'development' | 'production'; - staticDir?: string; - openBrowser?: boolean; - } = {} -) { - const port = options.port ?? DEFAULT_PORT; - const mode = options.mode ?? 'development'; - const registry = new FileRegistry(); - - // Register initial file's directory as allowed - if (options.initialFile) { - const dir = path.dirname(options.initialFile); - addAllowedDir(dir); - await registry.register(options.initialFile); - } - - const app = express(); - app.use(express.json({ limit: '10mb' })); - - // --- Host guard (DNS rebinding prevention) --- - app.use(createHostGuard(port)); - - // --- CORS (localhost only) --- - const allowedOrigins = [ - `http://localhost:${port}`, - `http://127.0.0.1:${port}`, - ]; - - app.use((_req, res, next) => { - const origin = _req.headers.origin; - if (origin && allowedOrigins.includes(origin)) { - res.setHeader('Access-Control-Allow-Origin', origin); - res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - } - if (_req.method === 'OPTIONS') { - res.sendStatus(204); - return; - } - next(); - }); - - // --- Health endpoint --- - app.get('/api/health', (_req, res) => { - res.json({ version: 2, mode: 'hub', fileCount: registry.listFiles().length }); - }); - - // --- File registration API --- - app.get('/api/files', (_req, res) => { - res.json({ files: registry.listFiles() }); - }); - - app.post('/api/files', async (req, res) => { - const { filePath } = req.body; - if (!filePath || typeof filePath !== 'string') { - res.status(400).json({ error: 'filePath required' }); - return; - } - try { - const validated = await validateFilePath(filePath); - addAllowedDir(path.dirname(validated)); - const alreadyRegistered = registry.getSync(validated) !== undefined; - await registry.register(validated); - res.status(alreadyRegistered ? 200 : 201).json({ - filePath: validated, - status: alreadyRegistered ? 'already_registered' : 'registered', - }); - } catch (err: any) { - if (err.message.includes('outside allowed') || err.message.includes('Only .vync')) { - res.status(403).json({ error: err.message }); - } else if (err.message.includes('Maximum')) { - res.status(429).json({ error: err.message }); - } else { - console.error('[vync] Registration error:', err); - res.status(500).json({ error: err.message }); - } - } - }); - - app.delete('/api/files', async (req, res) => { - const filePath = req.query.file as string; - const all = req.query.all === 'true'; - - if (all) { - await registry.shutdown(); - res.json({ status: 'all_unregistered' }); - return; - } - - if (!filePath) { - res.status(400).json({ error: 'file query param required' }); - return; - } - try { - await registry.unregister(filePath); - res.json({ status: 'unregistered', filePath }); - } catch (err: any) { - res.status(500).json({ error: err.message }); - } - }); - - // --- Sync API (file-scoped) --- - app.get('/api/sync', async (req, res) => { - const filePath = req.query.file as string; - if (!filePath) { - res.status(400).json({ error: 'file_required', files: registry.listFiles() }); - return; - } - const sync = registry.getSync(filePath); - if (!sync) { - res.status(404).json({ error: 'File not registered', filePath }); - return; - } - try { - const data = await sync.readFile(); - res.json(data); - } catch (err) { - console.error('[vync] Error reading file:', err); - res.status(500).json({ error: 'Failed to read file' }); - } - }); - - app.put('/api/sync', async (req, res) => { - const filePath = req.query.file as string; - if (!filePath) { - res.status(400).json({ error: 'file_required' }); - return; - } - const sync = registry.getSync(filePath); - if (!sync) { - res.status(404).json({ error: 'File not registered', filePath }); - return; - } - try { - const data = req.body as VyncFile; - if (!data || !Array.isArray(data.elements)) { - res.status(400).json({ error: 'Invalid VyncFile format' }); - return; - } - await sync.writeFile(data); - res.json({ ok: true }); - } catch (err) { - console.error('[vync] Error writing file:', err); - res.status(500).json({ error: 'Failed to write file' }); - } - }); - - // --- HTTP Server --- - const server = http.createServer(app); - - // --- Frontend serving --- - let vite: { close: () => Promise } | null = null; - - if (mode === 'production' && options.staticDir) { - app.use(express.static(options.staticDir)); - app.get('*', (_req, res) => { - res.sendFile(path.join(options.staticDir!, 'index.html')); - }); - } else if (mode === 'development') { - const { createServer: createViteServer } = await import('vite'); - const projectRoot = process.env.VYNC_HOME || process.cwd(); - const webAppRoot = path.resolve(projectRoot, 'apps/web'); - - vite = await createViteServer({ - configFile: path.resolve(webAppRoot, 'vite.config.ts'), - root: webAppRoot, - server: { middlewareMode: true, hmr: { server } }, - }); - - app.use(vite.middlewares); - } - - // --- WebSocket server --- - const ws = createWsServer(server, port, registry); - - // --- Shutdown --- - const shutdown = async () => { - console.log('\n[vync] Shutting down...'); - await registry.shutdown(); - ws.close(); - if (vite) await vite.close(); - await new Promise((resolve) => { - const timer = setTimeout(resolve, 3000); - server.close(() => { clearTimeout(timer); resolve(); }); - }); - }; - - const url = `http://localhost:${port}`; - - await new Promise((resolve, reject) => { - const onStartupError = (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - reject(new Error(`[vync] Port ${port} is already in use`)); - } else { - reject(err); - } - }; - server.once('error', onStartupError); - server.listen(port, '127.0.0.1', () => { - server.removeListener('error', onStartupError); - console.log(`[vync] Hub server running at ${url}`); - if (options.initialFile) { - console.log(`[vync] Initial file: ${options.initialFile}`); - } - resolve(); - }); - }); - - if (options.openBrowser && options.initialFile) { - const openModule = await import('open'); - await openModule.default(`${url}/?file=${encodeURIComponent(options.initialFile)}`); - } - - return { shutdown, server, url, registry }; -} - -// Direct execution -const isDirectRun = - process.argv[1]?.endsWith('server.ts') || - process.argv[1]?.endsWith('server.js'); - -if (isDirectRun) { - const filePath = process.argv[2]; - const resolvedFile = filePath ? path.resolve(filePath) : undefined; - if (resolvedFile) addAllowedDir(path.dirname(resolvedFile)); - - startServer({ initialFile: resolvedFile }) - .then(({ shutdown }) => { - process.on('SIGINT', async () => { await shutdown(); process.exit(0); }); - process.on('SIGTERM', async () => { await shutdown(); process.exit(0); }); - }) - .catch((err) => { - console.error('[vync] Fatal error:', err.message); - process.exit(1); - }); -} -``` - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run tools/server/__tests__/server-hub.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add tools/server/server.ts tools/server/__tests__/server-hub.test.ts -git commit -m "feat(server): rewrite as hub server with FileRegistry, file-scoped API, and security" -``` - ---- - -## Task 8: CLI — PID JSON + 2-state open + close 커맨드 - -**Files:** -- Modify: `tools/cli/open.ts` (대규모 수정) -- Modify: `tools/cli/main.ts:4-15,25-54` -- Test: `tools/cli/__tests__/open-hub.test.ts` (신규) - -**Step 1: Write the failing test** - -```typescript -// tools/cli/__tests__/open-hub.test.ts -import { describe, it, expect } from 'vitest'; -import { readServerInfo, writeServerInfo, type ServerInfo } from '../open.js'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; - -const VYNC_DIR = path.join(os.homedir(), '.vync'); -const PID_FILE = path.join(VYNC_DIR, 'server.pid'); - -describe('PID file JSON format', () => { - it('writes and reads JSON format', async () => { - const info: ServerInfo = { version: 2, pid: 99999, mode: 'daemon', port: 3100 }; - await writeServerInfo(info); - const read = await readServerInfo(); - expect(read).toEqual(info); - await fs.unlink(PID_FILE).catch(() => {}); - }); - - it('reads legacy 3-line format', async () => { - await fs.mkdir(VYNC_DIR, { recursive: true }); - await fs.writeFile(PID_FILE, '12345\ndaemon\n/path/to/file.vync'); - const read = await readServerInfo(); - expect(read).toEqual({ version: 1, pid: 12345, mode: 'daemon', port: 3100 }); - await fs.unlink(PID_FILE).catch(() => {}); - }); - - it('returns null for corrupt file', async () => { - await fs.mkdir(VYNC_DIR, { recursive: true }); - await fs.writeFile(PID_FILE, 'garbage'); - const read = await readServerInfo(); - expect(read).toBeNull(); - // Should have cleaned up - await expect(fs.access(PID_FILE)).rejects.toThrow(); - }); -}); -``` - -**Step 2: Run test to verify it fails** - -Run: `npx vitest run tools/cli/__tests__/open-hub.test.ts` -Expected: FAIL — ServerInfo type doesn't have `version`/`port`. - -**Step 3: Rewrite open.ts** - -This is a large file. Key changes: -- `ServerInfo` → JSON with version + port -- `readServerInfo` → JSON parse with legacy fallback -- `writeServerInfo` → JSON.stringify -- `handleExistingServer` → 2-state (server-up / server-down) -- `vyncOpen` → register via POST, not restart -- Health check → `/api/health` -- Browser URL → `?file=` param -- Add `vyncClose` export -- Polling → `/api/health` instead of `/api/sync` - -The full implementation code for open.ts should replace the existing file. Due to the size, here are the key structural changes: - -**ServerInfo:** -```typescript -export interface ServerInfo { - version: number; - pid: number; - mode: 'daemon' | 'electron' | 'foreground'; - port: number; -} -``` - -**readServerInfo (JSON + legacy):** -```typescript -export async function readServerInfo(): Promise { - try { - const content = (await fs.readFile(PID_FILE, 'utf-8')).trim(); - try { - const parsed = JSON.parse(content); - if (parsed.version && parsed.pid) return parsed; - } catch {} - // Legacy 3-line format - const lines = content.split('\n'); - if (lines.length >= 2 && !isNaN(Number(lines[0]))) { - return { version: 1, pid: Number(lines[0]), mode: lines[1] as any, port: PORT }; - } - await fs.unlink(PID_FILE).catch(() => {}); - return null; - } catch { return null; } -} -``` - -**handleExistingServer (2-state):** -```typescript -async function isServerRunning(): Promise<{ running: boolean; info: ServerInfo | null }> { - const info = await readServerInfo(); - if (!info) return { running: false, info: null }; - try { process.kill(info.pid, 0); } catch { - await fs.unlink(PID_FILE).catch(() => {}); - return { running: false, info: null }; - } - try { - const res = await fetch(`http://localhost:${info.port}/api/health`, { - signal: AbortSignal.timeout(1000), - }); - if (res.ok) { - const body = await res.json(); - if (body.version === 2) return { running: true, info }; - // Old server → stop it - await vyncStop(); - return { running: false, info: null }; - } - } catch {} - await fs.unlink(PID_FILE).catch(() => {}); - return { running: false, info: null }; -} -``` - -**vyncOpen (register mode):** -```typescript -export async function vyncOpen(filePath: string, opts = {}): Promise { - const resolved = await validateAndResolve(filePath); - const { running, info } = await isServerRunning(); - const port = info?.port ?? PORT; - - if (running) { - // Register file with running server - await registerFile(port, resolved); - await openBrowserWithFile(port, resolved); - return; - } - - // Start new server - if (opts.foreground) return runForeground(resolved); - const electronBinary = await findElectronBinary(); - if (electronBinary) return runElectron(resolved); - return runDaemon(resolved); -} -``` - -**registerFile helper:** -```typescript -async function registerFile(port: number, filePath: string): Promise { - const res = await fetch(`http://localhost:${port}/api/files`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath }), - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(`[vync] Registration failed: ${body.error || res.statusText}`); - } -} -``` - -**openBrowserWithFile helper:** -```typescript -async function openBrowserWithFile(port: number, filePath: string): Promise { - const openModule = await import('open'); - await openModule.default(`http://localhost:${port}/?file=${encodeURIComponent(filePath)}`); -} -``` - -**vyncClose (신규):** -```typescript -export async function vyncClose(filePath?: string, opts?: { keepServer?: boolean }): Promise { - const info = await readServerInfo(); - if (!info) { console.error('[vync] No running server.'); return; } - - if (filePath) { - const resolved = resolveVyncPath(filePath); - const res = await fetch( - `http://localhost:${info.port}/api/files?file=${encodeURIComponent(resolved)}`, - { method: 'DELETE' } - ); - if (res.ok) console.log(`[vync] Closed: ${resolved}`); - } else { - // Close all - await fetch(`http://localhost:${info.port}/api/files?all=true`, { method: 'DELETE' }); - console.log('[vync] All files closed.'); - } - - // Check if server should stop - if (!opts?.keepServer) { - const filesRes = await fetch(`http://localhost:${info.port}/api/files`).catch(() => null); - const body = filesRes ? await filesRes.json() : { files: [] }; - if (body.files.length === 0) { - await vyncStop(); - } - } -} -``` - -**main.ts** — add close command: -```typescript -case 'close': { - const keepServer = args.includes('--keep-server'); - const filePath = args.find((a) => !a.startsWith('--')); - await vyncClose(filePath, { keepServer }); - break; -} -``` - -**Polling in runDaemon/runElectron** — change from `/api/sync` to `/api/health`: -```typescript -const res = await fetch(`${url}/api/health`); -if (res.ok) { /* ready */ } -``` - -**Step 4: Run test to verify it passes** - -Run: `npx vitest run tools/cli/__tests__/open-hub.test.ts` -Expected: PASS - -**Step 5: Commit** - -```bash -git add tools/cli/open.ts tools/cli/main.ts tools/cli/__tests__/open-hub.test.ts -git commit -m "feat(cli): hub-mode PID (JSON), 2-state open, close command, register-based workflow" -``` - ---- - -## Task 9: Frontend — FileBoard 컴포넌트 + ?file= 파라미터 - -**Files:** -- Create: `apps/web/src/app/file-board.tsx` -- Modify: `apps/web/src/app/app.tsx` (슬림화) - -**Step 1: Extract FileBoard from App** - -Create `apps/web/src/app/file-board.tsx` with the existing board logic, parameterized by `filePath`: -- Props: `{ filePath: string }` -- All existing state (value, syncMode, wsRef, etc.) moves here -- API calls use `?file=` param -- WebSocket uses `?file=` param -- localStorage key: `vync_board_${filePath}` -- **Viewport 변경**: WebSocket `file-changed` 메시지에서 viewport 무시 (elements만 업데이트) -- Legacy localStorage migration (한 번만 실행) - -**Step 2: Rewrite App as thin wrapper** - -```typescript -// apps/web/src/app/app.tsx -import { FileBoard } from './file-board'; - -export function App() { - const filePath = new URLSearchParams(window.location.search).get('file'); - - if (!filePath) { - return ( -
-
-

No file specified

-

Use vync open <file> to start.

-
-
- ); - } - - return ; -} - -export default App; -``` - -**Step 3: Implement FileBoard** - -Key changes from the original App: -```typescript -// API calls -fetch(`/api/sync?file=${encodeURIComponent(filePath)}`) -fetch(`/api/sync?file=${encodeURIComponent(filePath)}`, { method: 'PUT', ... }) - -// WebSocket -const wsUrl = `${protocol}//${window.location.host}/ws?file=${encodeURIComponent(filePath)}`; - -// LocalStorage -const storageKey = `vync_board_${filePath}`; - -// Viewport: on WS file-changed, only update elements, not viewport -if (msg.type === 'file-changed' && msg.data) { - remoteUpdateUntilRef.current = Date.now() + 500; - setValue((prev) => ({ - ...prev, - children: msg.data!.elements || [], - // viewport NOT updated — keep current tab's viewport - })); -} - -// Reconnect recovery: POST /api/files to re-register if GET returns 404 -if (res.status === 404) { - await fetch('/api/files', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath }), - }); - // Retry -} - -// Legacy migration (once) -const legacyData = await localforage.getItem('main_board_content'); -if (legacyData && !await localforage.getItem(storageKey)) { - await localforage.setItem(storageKey, legacyData); -} -``` - -**Step 4: Verify dev server works** - -Run: `npm run dev:server` (with a test .vync file) -Open: `http://localhost:3100/?file=` in browser -Expected: Board loads correctly. - -**Step 5: Commit** - -```bash -git add apps/web/src/app/file-board.tsx apps/web/src/app/app.tsx -git commit -m "feat(web): extract FileBoard component with file-scoped sync, viewport isolation" -``` - ---- - -## Task 10: Electron — 파일 등록 모드 - -**Files:** -- Modify: `tools/electron/main.ts:66-98` - -**Step 1: Update openFile() to register instead of restart** - -```typescript -async function openFile(filePath: string): Promise { - const resolved = path.resolve(filePath); - - if (serverHandle) { - // Hub mode: register new file, don't restart - try { - await fetch(`${serverHandle.url}/api/files`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath: resolved }), - }); - } catch (err: any) { - dialog.showErrorBox('Vync Error', `Failed to register file: ${err.message}`); - return; - } - } else { - // Start server - try { - const { startServer } = await import('../server/server.js'); - const isDev = !app.isPackaged; - const staticDir = isDev - ? undefined - : path.join(process.resourcesPath, 'dist', 'apps', 'web'); - serverHandle = await startServer({ - initialFile: resolved, - port: 3100, - mode: isDev ? 'development' : 'production', - staticDir, - }); - } catch (err: any) { - dialog.showErrorBox('Vync Error', err.message); - app.quit(); - return; - } - } - - const fileUrl = `${serverHandle!.url}/?file=${encodeURIComponent(resolved)}`; - if (!mainWindow) { - createWindow(fileUrl); - } else { - mainWindow.loadURL(fileUrl); - } -} -``` - -**Step 2: Update second-instance handler** - -```typescript -app.on('second-instance', (_event, argv) => { - const file = argv.find((a) => a.endsWith('.vync')); - if (file) openFile(file); - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - } -}); -``` - -(This already calls `openFile` which now registers instead of restarts.) - -**Step 3: Verify Electron compiles** - -Run: `npx esbuild tools/electron/main.ts --bundle --platform=node --outfile=dist/electron/main.js --packages=external` -Expected: Build succeeds. - -**Step 4: Commit** - -```bash -git add tools/electron/main.ts -git commit -m "feat(electron): register files with hub server instead of restarting" -``` - ---- - -## Task 11: Hooks — SessionEnd 업데이트 - -**Files:** -- Modify: `.claude-plugin/hooks.json:14-24` - -**Step 1: Update SessionEnd hook** - -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "jq -r '.tool_input.file_path // \"\"' | { read f; [[ \"$f\" == *.vync ]] && node \"$HOME/.claude/skills/vync-editing/scripts/validate.js\" \"$f\" 2>&1 || true; }" - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "curl -s -X DELETE 'http://localhost:3100/api/files?all=true' 2>/dev/null; [ -f \"$HOME/.vync/server.pid\" ] && { pid=$(node -e \"try{console.log(JSON.parse(require('fs').readFileSync('$HOME/.vync/server.pid','utf8')).pid)}catch{const l=require('fs').readFileSync('$HOME/.vync/server.pid','utf8').split('\\n');console.log(l[0])}\" 2>/dev/null || head -1 \"$HOME/.vync/server.pid\"); kill \"$pid\" 2>/dev/null; rm -f \"$HOME/.vync/server.pid\"; }; exit 0" - } - ] - } - ] - } -} -``` - -**Step 2: Re-run install.sh** - -Run: `bash .claude-plugin/install.sh` -Expected: Hooks merged into `~/.claude/settings.json`. - -**Step 3: Commit** - -```bash -git add .claude-plugin/hooks.json -git commit -m "fix(hooks): update SessionEnd to unregister files before killing hub server" -``` - ---- - -## Task 12: /vync 커맨드 — close 서브커맨드 문서화 - -**Files:** -- Modify: `.claude-plugin/commands/vync.md` - -**Step 1: Add close subcommand** - -Add to the CLI subcommands section: -```markdown -- `close [file]` — Unregister file from server. If no files remain, server stops. - - `--keep-server` — Unregister but keep server running. -``` - -**Step 2: Update open description** - -```markdown -- `open ` — Register file with hub server and open browser. Starts server if not running. -``` - -**Step 3: Commit** - -```bash -git add .claude-plugin/commands/vync.md -git commit -m "docs(plugin): add close subcommand, update open description for hub mode" -``` - ---- - -## Task 13: Integration Test — 멀티 파일 E2E - -**Files:** -- Create: `tools/server/__tests__/multi-file-e2e.test.ts` - -**Step 1: Write integration test** - -```typescript -// tools/server/__tests__/multi-file-e2e.test.ts -import { describe, it, expect, afterEach } from 'vitest'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { WebSocket } from 'ws'; -import { addAllowedDir, clearAllowedDirs } from '../security.js'; - -describe('Multi-file E2E', () => { - const tmpDir = path.join(os.tmpdir(), `vync-e2e-${Date.now()}`); - let shutdownFn: (() => Promise) | null = null; - - afterEach(async () => { - if (shutdownFn) { await shutdownFn(); shutdownFn = null; } - clearAllowedDirs(); - await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); - }); - - it('two files: independent editing + WS isolation', async () => { - await fs.mkdir(tmpDir, { recursive: true }); - const fileA = path.join(tmpDir, 'a.vync'); - const fileB = path.join(tmpDir, 'b.vync'); - const baseData = { version: 1, viewport: { zoom: 1, x: 0, y: 0 }, elements: [] }; - await fs.writeFile(fileA, JSON.stringify(baseData)); - await fs.writeFile(fileB, JSON.stringify(baseData)); - addAllowedDir(tmpDir); - - const { startServer } = await import('../server.js'); - const port = 3300 + Math.floor(Math.random() * 100); - const result = await startServer({ port, mode: 'production' }); - shutdownFn = result.shutdown; - - const base = `http://localhost:${port}`; - - // Register both files - await fetch(`${base}/api/files`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filePath: fileA }) }); - await fetch(`${base}/api/files`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filePath: fileB }) }); - - // Verify both accessible - const resA = await fetch(`${base}/api/sync?file=${encodeURIComponent(fileA)}`); - const resB = await fetch(`${base}/api/sync?file=${encodeURIComponent(fileB)}`); - expect(resA.ok).toBe(true); - expect(resB.ok).toBe(true); - - // Write to file A - await fetch(`${base}/api/sync?file=${encodeURIComponent(fileA)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ...baseData, elements: [{ id: 'aaaaa' }] }), - }); - - // File B should be unchanged - const bData = await (await fetch(`${base}/api/sync?file=${encodeURIComponent(fileB)}`)).json(); - expect(bData.elements).toHaveLength(0); - - // File A should have the element - const aData = await (await fetch(`${base}/api/sync?file=${encodeURIComponent(fileA)}`)).json(); - expect(aData.elements).toHaveLength(1); - - // Unregister file A - await fetch(`${base}/api/files?file=${encodeURIComponent(fileA)}`, { method: 'DELETE' }); - - // File A should be 404 now - const resA2 = await fetch(`${base}/api/sync?file=${encodeURIComponent(fileA)}`); - expect(resA2.status).toBe(404); - - // File B still works - const resB2 = await fetch(`${base}/api/sync?file=${encodeURIComponent(fileB)}`); - expect(resB2.ok).toBe(true); - }); - - it('security: rejects non-.vync files', async () => { - const { startServer } = await import('../server.js'); - const port = 3300 + Math.floor(Math.random() * 100); - const result = await startServer({ port, mode: 'production' }); - shutdownFn = result.shutdown; - - const res = await fetch(`http://localhost:${port}/api/files`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath: '/etc/passwd' }), - }); - expect(res.status).toBe(403); - }); -}); -``` - -**Step 2: Run test** - -Run: `npx vitest run tools/server/__tests__/multi-file-e2e.test.ts` -Expected: PASS - -**Step 3: Commit** - -```bash -git add tools/server/__tests__/multi-file-e2e.test.ts -git commit -m "test: add multi-file E2E integration tests with security validation" -``` - ---- - -## Task 14: Docs — DECISIONS.md + ARCHITECTURE.md 업데이트 - -**Files:** -- Modify: `docs/DECISIONS.md` — D-014 추가 -- Modify: `docs/ARCHITECTURE.md` — 멀티 파일 반영 -- Modify: `docs/PLAN.md` — Phase 8 추가 - -**Step 1: Add D-014** - -```markdown -| D-014 | Multi-file hub server | 단일 서버(:3100), 다중 파일. FileRegistry가 파일별 SyncService/Watcher/WS 관리. | 멀티 인스턴스 대비 리소스 효율. 탭+윈도우 모두 가능한 유일한 선택. | 2026-03-09 | -``` - -**Step 2: Update ARCHITECTURE.md sync mechanism** - -Update the sync flow diagram to show file-scoped routing. - -**Step 3: Update PLAN.md** - -Add Phase 8 (Multi-file hub) with completion criteria. - -**Step 4: Commit** - -```bash -git add docs/DECISIONS.md docs/ARCHITECTURE.md docs/PLAN.md -git commit -m "docs: add D-014 multi-file hub, update architecture and plan for Phase 8" -``` - ---- - -## Task 15: 전체 테스트 실행 + 최종 검증 - -**Step 1: Run all tests** - -Run: `npx vitest run` -Expected: All tests PASS. - -**Step 2: Dev server manual test** - -```bash -# Terminal 1: Start server -npx tsx tools/server/server.ts - -# Terminal 2: Register two files -curl -X POST http://localhost:3100/api/files -H 'Content-Type: application/json' -d '{"filePath":"/path/to/a.vync"}' -curl -X POST http://localhost:3100/api/files -H 'Content-Type: application/json' -d '{"filePath":"/path/to/b.vync"}' - -# Browser: Open two tabs -open "http://localhost:3100/?file=/path/to/a.vync" -open "http://localhost:3100/?file=/path/to/b.vync" - -# Verify: Both tabs show different content, edits are independent -``` - -**Step 3: CLI manual test** - -```bash -vync open plan # Starts server, opens browser -vync open design # Registers second file, opens new tab -vync close plan # Unregisters plan, design still works -vync stop # Stops server -``` - -**Step 4: Final commit** - -```bash -git add -A -git commit -m "feat: multi-file hub server (Stage 1 complete)" -``` - ---- - -## Dependency Graph - -``` -Task 1 (types) - ↓ -Task 2 (security) - ↓ -Task 3 (drain) ─────────→ Task 5 (FileRegistry) - ↓ ↓ -Task 4 (watcher unlink) ────→ Task 6 (WS handler) - ↓ - Task 7 (server hub) - ↓ - ┌───────────┼───────────┐ - ↓ ↓ ↓ - Task 8 (CLI) Task 9 (FE) Task 10 (Electron) - ↓ ↓ ↓ - Task 11 (hooks) Task 12 (docs) - ↓ - Task 13 (E2E) - ↓ - Task 14 (docs) - ↓ - Task 15 (verify) -``` - -Tasks 1-4는 독립적으로 병렬 실행 가능. Tasks 8-10도 Task 7 완료 후 병렬 가능. - ---- - -## Stage 2 동기화 체크포인트 - -> **1단계 완료 후 반드시 수행:** -> 1. `docs/plans/2026-03-09-multi-file-hub-design.md`의 Stage 2 Preview 섹션 재검토 -> 2. 1단계에서 확정된 API 계약, WsMessage 프로토콜, FileBoard 구조 기반으로 2단계 세부 계획 작성 -> 3. `docs/plans/YYYY-MM-DD-multi-file-tab-ui-design.md`로 저장 -> 4. 특히 확인: `remoteUpdateUntilRef` → `Map` 전환 필요 여부, Electron 멀티 윈도우 vs 탭 결정 diff --git a/docs/archive/2026-03-09-multi-tab-ui-design.md b/docs/archive/2026-03-09-multi-tab-ui-design.md deleted file mode 100644 index 5c7fa8d..0000000 --- a/docs/archive/2026-03-09-multi-tab-ui-design.md +++ /dev/null @@ -1,558 +0,0 @@ -# Design: Multi-Tab UI (Phase 8 Stage 2) - -**Date:** 2026-03-09 -**Status:** Implemented -**Depends on:** Phase 8 Stage 1 (완료) - -## Goal - -단일 브라우저 탭/Electron 윈도우 안에서 여러 `.vync` 파일을 탭으로 전환하며 작업할 수 있는 UI를 구현한다. - -- 상단 수평 탭 바 (Chrome/VS Code 스타일) -- `vync open B.vync` → 기존 브라우저의 탭 바에 실시간 추가 -- 탭 전환으로 파일 간 빠른 이동 -- Electron에서도 동일한 UI (프론트엔드 공유) - -## Decisions - -| ID | Decision | Rationale | -|----|----------|-----------| -| M-9 | 상단 수평 탭 바 | Chrome/VS Code 익숙한 UX. 캔버스 영역 최대화. | -| M-10 | Active 탭만 마운트 (mount/unmount) | Plait board re-init ~0.6초 허용 가능. WS 연결 1개만 유지. `display:none` 시 canvas/SVG 호환성 리스크 회피. | -| M-11 | Hub-level WebSocket | 파일 등록/해제를 실시간으로 모든 프론트엔드에 알림. polling 불필요. 향후 대시보드/파일 간 링크에도 재사용 가능. | -| M-12 | 탭 닫기(×) ≠ vync close | 탭 × = UI에서 숨기기 (되돌릴 수 있음). vync close = 서버 해제 (watcher/WS 정리). 다른 세션 보호. | -| M-13 | Electron = 프론트엔드 탭 UI | thin shell 아키텍처(D-012) 유지. 프론트엔드 변경만으로 자동 적용. 추가 Electron 코드 최소. | -| M-14 | CLI에서만 파일 열기 | Vync 철학(CLI 중심, D-006) 유지. + 버튼은 등록된 파일 중 탭에 없는 것 선택. 파일 시스템 브라우저 없음. | -| M-15 | 중복 파일명 disambiguate | 같은 basename 존재 시 상위 디렉토리 포함 (`proj1/plan.vync`). 고유하면 basename만. | - -## Architecture - -### Component Structure - -``` -App - ├─ useHubWebSocket() ← hub WS 연결 (파일 등록/해제 이벤트) - ├─ tabs state ← [{filePath, label}, ...] - ├─ activeFilePath state - │ - ├─ TabBar - │ ├─ Tab (plan.vync) [active] ← 클릭: setActiveFilePath - │ ├─ Tab (arch.vync) ← × 클릭: 탭 목록에서 제거 (서버 유지) - │ └─ AddTabButton (+) ← 드롭다운: 등록 파일 중 탭에 없는 것 - │ - └─ FileBoard (key={activeFilePath}) ← active만 마운트. key 변경 시 unmount/mount. - ├─ file-scoped WS 연결 - ├─ API calls - └─ Plait Board -``` - -### Hub WebSocket Protocol - -**연결:** `ws://localhost:3100/ws` (file 파라미터 없음) - -현재 `?file=` 없는 WS 연결은 에러(4400)로 거부됨. 이를 hub client 모드로 변경. - -**서버 → 클라이언트:** - -```typescript -// 연결 시 — 현재 등록 파일 목록 전달 -{ type: "connected", data: { files: ["/path/A.vync", "/path/B.vync"] } } - -// 파일 등록 시 -{ type: "hub-file-registered", filePath: "/path/to/C.vync" } - -// 파일 해제 시 (vync close, idle timeout, etc.) -{ type: "hub-file-unregistered", filePath: "/path/to/C.vync" } -``` - -**WsMessage 타입 확장:** -```typescript -interface WsMessage { - type: 'file-changed' | 'connected' | 'file-closed' | 'file-deleted' | 'error' - | 'hub-file-registered' | 'hub-file-unregistered'; // 신규 - filePath?: string; - data?: VyncFile | { files: string[] }; // connected에서 hub 모드 시 파일 목록 - code?: string; -} -``` - -### Tab State Management - -```typescript -interface TabInfo { - filePath: string; // 절대경로 (식별자) - label: string; // 표시명 (basename or disambiguated) -} - -// App state -const [tabs, setTabs] = useState([]); -const [activeFilePath, setActiveFilePath] = useState(null); -``` - -**초기화 흐름:** -1. App 마운트 → Hub WS 연결 -2. Hub WS `connected` 수신 → `data.files`로 탭 초기화 -3. URL `?file=` 파라미터 → 해당 파일을 active로 설정 -4. URL에 `?file=` 없음 → 첫 번째 등록 파일 active (없으면 빈 화면) - -**실시간 업데이트:** -- `hub-file-registered` → 탭 목록에 추가 (active 전환 안 함 — 현재 작업 방해 방지) -- `hub-file-unregistered` → 탭 목록에서 제거. active가 해당 파일이면 다음 탭으로 전환. - -**탭 닫기(×):** -- 탭 목록에서만 제거 (서버 등록 유지) -- + 버튼 드롭다운에 다시 나타남 -- 마지막 탭 닫기 → 빈 화면 ("No file selected") - -**URL 동기화:** -- 탭 전환 시 `history.replaceState`로 `?file=` 갱신 -- 새로고침 시 같은 파일 active -- pushState 아님 (뒤로가기 이력 오염 방지) - -### Tab Label Disambiguate - -```typescript -function computeLabels(filePaths: string[]): Map { - const basenames = filePaths.map(fp => path.basename(fp)); - const counts = new Map(); - for (const bn of basenames) { - counts.set(bn, (counts.get(bn) || 0) + 1); - } - - const labels = new Map(); - for (const fp of filePaths) { - const bn = path.basename(fp); - if (counts.get(bn)! > 1) { - // 중복: 상위 디렉토리 포함 - const parent = path.basename(path.dirname(fp)); - labels.set(fp, `${parent}/${bn}`); - } else { - labels.set(fp, bn); - } - } - return labels; -} -``` - -### Add Tab (+) Dropdown - -``` -[+] 클릭 시: -┌────────────────────────┐ -│ arch.vync │ ← 서버에 등록되었지만 현재 탭에 없는 파일 -│ notes.vync │ -├────────────────────────┤ -│ (no more files) │ ← 모든 파일이 이미 탭에 있을 때 -│ Use `vync open` to │ -│ register new files │ -└────────────────────────┘ -``` - -- 드롭다운 항목 = `registeredFiles.filter(f => !tabs.some(t => t.filePath === f))` -- 클릭 → 탭 추가 + active 설정 - -### No File State - -등록 파일 0개이거나 모든 탭을 닫았을 때: - -``` -┌──────────────────────────────────────┐ -│ [+] │ -├──────────────────────────────────────┤ -│ │ -│ No file selected │ -│ Use `vync open ` to start │ -│ │ -└──────────────────────────────────────┘ -``` - -## Server Changes - -### 1. `packages/shared/src/types.ts` - -WsMessage type에 `hub-file-registered`, `hub-file-unregistered` 추가. - -### 2. `tools/server/file-registry.ts` - -```typescript -class FileRegistry extends EventEmitter { - // 기존 필드... - private hubClients = new Set(); - - addHubClient(ws: WebSocket): void { - this.hubClients.add(ws); - } - - removeHubClient(ws: WebSocket): void { - this.hubClients.delete(ws); - } - - private broadcastToHub(message: WsMessage): void { - const data = JSON.stringify(message); - for (const client of this.hubClients) { - if (client.readyState === 1) { - client.send(data); - } - } - } - - async register(filePath: string): Promise { - // ... 기존 로직 ... - this.emit('registered', validated); - this.broadcastToHub({ type: 'hub-file-registered', filePath: validated }); - } - - async unregister(filePath: string): Promise { - // ... 기존 로직 ... - this.emit('unregistered', filePath); - this.broadcastToHub({ type: 'hub-file-unregistered', filePath }); - // ... - } - - async shutdown(): Promise { - // 기존 파일 정리 + hub 클라이언트 정리 - for (const ws of this.hubClients) { - ws.close(1001, 'Server shutting down'); - } - this.hubClients.clear(); - } -} -``` - -### 3. `tools/server/ws-handler.ts` - -```typescript -wss.on('connection', (ws, _request, filePath) => { - if (!filePath) { - // Hub mode: 파일에 바인딩되지 않은 허브 클라이언트 - registry.addHubClient(ws); - ws.send(JSON.stringify({ - type: 'connected', - data: { files: registry.listFiles() }, - } satisfies WsMessage)); - - ws.on('close', () => { - registry.removeHubClient(ws); - }); - return; - } - - // 기존 file-scoped 로직... -}); -``` - -## Frontend Changes - -### 4. `apps/web/src/app/tab-bar.tsx` (신규) - -```typescript -interface TabBarProps { - tabs: TabInfo[]; - activeFilePath: string | null; - registeredFiles: string[]; - onTabClick: (filePath: string) => void; - onTabClose: (filePath: string) => void; - onAddFile: (filePath: string) => void; -} -``` - -- 각 탭: 파일 라벨 + × 닫기 버튼 -- Active 탭: 하단 border accent + 배경색 구분 -- × 버튼: hover 시 표시, active는 항상 표시 -- + 버튼: 클릭 시 드롭다운 -- 오버플로우: `overflow-x: auto` + `flex-nowrap` -- Tooltip: 전체 절대경로 (title 속성) - -### 5. `apps/web/src/app/tab-bar.scss` (신규) - -```scss -.vync-tab-bar { - display: flex; - align-items: stretch; - height: 36px; - background: #f0f0f0; - border-bottom: 1px solid #ddd; - overflow-x: auto; - flex-shrink: 0; - - &::-webkit-scrollbar { height: 0; } -} - -.vync-tab { - display: flex; - align-items: center; - padding: 0 12px; - gap: 6px; - cursor: pointer; - border-right: 1px solid #ddd; - white-space: nowrap; - font-size: 13px; - color: #666; - user-select: none; - min-width: 0; - - &:hover { background: #e8e8e8; } - - &--active { - background: #fff; - color: #333; - border-bottom: 2px solid #4a9eff; - } -} - -.vync-tab__close { - opacity: 0; - border: none; - background: none; - cursor: pointer; - padding: 2px; - border-radius: 3px; - font-size: 12px; - color: #999; - - .vync-tab:hover &, - .vync-tab--active & { opacity: 1; } - &:hover { background: #ddd; color: #333; } -} - -.vync-tab-add { - display: flex; - align-items: center; - padding: 0 10px; - cursor: pointer; - color: #999; - font-size: 16px; - position: relative; - - &:hover { color: #333; background: #e8e8e8; } -} - -.vync-tab-dropdown { - position: absolute; - top: 100%; - left: 0; - background: #fff; - border: 1px solid #ddd; - border-radius: 4px; - box-shadow: 0 4px 12px rgba(0,0,0,0.1); - min-width: 180px; - z-index: 100; - padding: 4px 0; -} - -.vync-tab-dropdown__item { - padding: 6px 12px; - cursor: pointer; - font-size: 13px; - &:hover { background: #f0f0f0; } -} - -.vync-tab-dropdown__empty { - padding: 8px 12px; - color: #999; - font-size: 12px; -} -``` - -### 6. `apps/web/src/app/app.tsx` (리팩토링) - -```typescript -function App() { - const [tabs, setTabs] = useState([]); - const [activeFilePath, setActiveFilePath] = useState(null); - const [registeredFiles, setRegisteredFiles] = useState([]); - - // URL ?file= → 초기 active 파일 - const initialFile = useMemo( - () => new URLSearchParams(window.location.search).get('file'), - [] - ); - - // Hub WebSocket - useEffect(() => { - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${location.host}/ws`; // file 파라미터 없음 - let ws: WebSocket; - let reconnectTimer: ReturnType; - - const connect = () => { - ws = new WebSocket(wsUrl); - - ws.onmessage = (e) => { - const msg = JSON.parse(e.data); - - if (msg.type === 'connected' && msg.data?.files) { - const files: string[] = msg.data.files; - setRegisteredFiles(files); - setTabs(files.map(fp => ({ filePath: fp, label: '' }))); - setActiveFilePath(prev => prev || initialFile || files[0] || null); - } - - if (msg.type === 'hub-file-registered') { - setRegisteredFiles(prev => [...new Set([...prev, msg.filePath])]); - setTabs(prev => { - if (prev.some(t => t.filePath === msg.filePath)) return prev; - return [...prev, { filePath: msg.filePath, label: '' }]; - }); - } - - if (msg.type === 'hub-file-unregistered') { - setRegisteredFiles(prev => prev.filter(f => f !== msg.filePath)); - setTabs(prev => prev.filter(t => t.filePath !== msg.filePath)); - setActiveFilePath(prev => - prev === msg.filePath - ? tabs.find(t => t.filePath !== msg.filePath)?.filePath || null - : prev - ); - } - }; - - ws.onclose = () => { - reconnectTimer = setTimeout(connect, 3000); - }; - ws.onerror = () => ws.close(); - }; - - connect(); - return () => { - ws?.close(); - clearTimeout(reconnectTimer); - }; - }, []); - - // URL 동기화 - useEffect(() => { - if (activeFilePath) { - const url = new URL(window.location.href); - url.searchParams.set('file', activeFilePath); - history.replaceState(null, '', url.toString()); - } - }, [activeFilePath]); - - // Label 계산 (disambiguate) - const tabsWithLabels = useMemo(() => computeLabels(tabs), [tabs]); - - // 탭 닫기 - const handleTabClose = (filePath: string) => { - setTabs(prev => prev.filter(t => t.filePath !== filePath)); - if (activeFilePath === filePath) { - const remaining = tabs.filter(t => t.filePath !== filePath); - setActiveFilePath(remaining[0]?.filePath || null); - } - }; - - // 파일 추가 (+ 드롭다운에서 선택) - const handleAddFile = (filePath: string) => { - setTabs(prev => { - if (prev.some(t => t.filePath === filePath)) return prev; - return [...prev, { filePath, label: '' }]; - }); - setActiveFilePath(filePath); - }; - - if (tabs.length === 0 && !activeFilePath) { - return ; - } - - return ( -
- - {activeFilePath - ? - : - } -
- ); -} -``` - -### 7. `apps/web/src/app/file-board.tsx` - -변경 없음. 이미 `key={activeFilePath}`로 mount/unmount 지원. - -## Electron Changes - -### 8. `tools/electron/main.ts` - -현재: `openFile()` → 매번 `mainWindow.loadURL(url)` (페이지 리로드). - -변경: -- 첫 번째 파일: `loadURL()` (윈도우 생성) -- 두 번째 이후: `POST /api/files`만 (hub WS가 프론트엔드에 알림, 리로드 없음) - -```typescript -async function openFile(filePath: string) { - if (serverHandle) { - await fetch(`${serverHandle.url}/api/files`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath }), - }); - } else { - const mod = await import('../server/server.js'); - serverHandle = await mod.startServer({ initialFile: filePath, ... }); - } - - if (!mainWindow) { - const url = `${serverHandle.url}/?file=${encodeURIComponent(filePath)}`; - createWindow(url); - } - // 이미 윈도우 있으면 loadURL 안 함 — hub WS가 탭 추가 알림 -} -``` - -## Implementation Tasks - -| # | Task | File(s) | Depends | -|---|------|---------|---------| -| 1 | WsMessage 타입 확장 | `packages/shared/src/types.ts` | — | -| 2 | FileRegistry hub 클라이언트 관리 | `tools/server/file-registry.ts` | 1 | -| 3 | WS handler hub 모드 | `tools/server/ws-handler.ts` | 2 | -| 4 | Hub WS 서버 테스트 | `tools/server/__tests__/` | 3 | -| 5 | TabBar 컴포넌트 | `apps/web/src/app/tab-bar.tsx` | — | -| 6 | TabBar 스타일 | `apps/web/src/app/tab-bar.scss` | 5 | -| 7 | App 리팩토링 (탭 상태 + hub WS) | `apps/web/src/app/app.tsx` | 3, 5 | -| 8 | Label disambiguate 유틸리티 | `apps/web/src/app/tab-utils.ts` | — | -| 9 | Electron openFile 최적화 | `tools/electron/main.ts` | 7 | -| 10 | 통합 E2E 검증 | — | 9 | -| 11 | 문서 업데이트 | `PLAN.md`, `ARCHITECTURE.md`, `CLAUDE.md` | 10 | - -**구현 순서:** 1→2→3→4 (서버) → 5,6,8 병렬 → 7 → 9 → 10 → 11 - -## Testing Strategy - -### 서버 유닛 테스트 (Task 4) -- Hub WS 연결 시 `connected` + 파일 목록 수신 -- `POST /api/files` → hub 클라이언트가 `hub-file-registered` 수신 -- `DELETE /api/files` → hub 클라이언트가 `hub-file-unregistered` 수신 -- Hub 클라이언트 disconnect → 정상 정리 (에러 없음) -- File-scoped WS와 hub WS 독립 동작 - -### 프론트엔드 E2E (Task 10, 수동) -- `vync open A.vync` → 탭 바에 [A] 표시 -- `vync open B.vync` → 기존 탭 바에 [B] 실시간 추가 (active 전환 안 함) -- 탭 클릭 → FileBoard 전환 (보드 로드) -- × 클릭 → 탭 제거 (서버 등록 유지) -- + 클릭 → 드롭다운에 미열린 파일 표시 -- `vync close A.vync` → A 탭 자동 제거 -- 모든 탭 닫기 → "No file selected" 표시 -- 새로고침 → URL ?file= 기반으로 같은 파일 active - -## Scope Exclusions - -| 제외 항목 | 이유 | -|-----------|------| -| 탭 드래그 정렬 | 복잡도 높음. 현재 사용 패턴에서 불필요. | -| 탭 순서 persist (localStorage) | 세션 간 탭 순서 유지는 과도한 최적화. | -| 탭 닫기 확인 다이얼로그 | 탭 닫기 = UI 숨기기라 데이터 손실 없음. | -| 탭 컨텍스트 메뉴 (우클릭) | Phase 범위 초과. | -| 탭 핀 고정 | Phase 범위 초과. | -| UI 파일 열기 (파일 시스템 브라우저) | CLI 중심 철학 유지 (M-14). | -| Electron 멀티 윈도우 | 프론트엔드 탭 UI로 충분 (M-13). | diff --git a/docs/archive/2026-03-09-smart-restart-verification.md b/docs/archive/2026-03-09-smart-restart-verification.md deleted file mode 100644 index 1efea5c..0000000 --- a/docs/archive/2026-03-09-smart-restart-verification.md +++ /dev/null @@ -1,461 +0,0 @@ -# Smart Restart + Auto-Open 검증 계획 - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** `vync open` 스마트 재시작과 `/vync-create` 자동 열기의 정확한 동작을 유닛 테스트 + 수동 E2E로 검증한다. - -**Architecture:** `readServerInfo`/`writeServerInfo`/`handleExistingServer` 등 순수 함수는 vitest 유닛 테스트로, 프로세스 spawn/서버 기동이 필요한 통합 시나리오는 수동 CLI로 검증한다. 테스트 격리를 위해 PID_FILE/VYNC_DIR을 tmp 디렉토리로 오버라이드하는 테스트 헬퍼를 사용한다. - -**Tech Stack:** Vitest, Node.js fs mock (실제 tmp 디렉토리), Bash CLI - ---- - -## Task 1: 테스트 가능하도록 상수/헬퍼 export - -현재 `readServerInfo`, `writeServerInfo`, `handleExistingServer` 등이 모듈 내부 함수로 export되지 않아 직접 테스트 불가. 테스트에 필요한 최소 항목만 export한다. - -**Files:** -- Modify: `tools/cli/open.ts` -- Create: `tools/cli/__tests__/open.test.ts` - -**Step 1: export 추가** - -`tools/cli/open.ts`에서 테스트에 필요한 타입과 함수를 export: - -```typescript -// 기존 interface를 export로 변경 -export interface ServerInfo { - pid: number; - mode: 'daemon' | 'electron' | 'foreground'; - filePath: string; -} - -// 기존 함수를 export로 변경 -export async function readServerInfo(): Promise { ... } -export async function writeServerInfo(info: ServerInfo): Promise { ... } - -// 테스트에서 경로를 오버라이드할 수 있도록 getter 추가 -export function getPidFilePath(): string { return PID_FILE; } -export function getVyncDir(): string { return VYNC_DIR; } -``` - -변경 대상 (각각 앞에 `export` 키워드 추가): -- L17: `interface ServerInfo` → `export interface ServerInfo` -- L23: `async function readServerInfo` → `export async function readServerInfo` -- L43: `async function writeServerInfo` → `export async function writeServerInfo` -- 파일 끝에 `getPidFilePath`와 `getVyncDir` 추가 - -**Step 2: 기존 테스트가 깨지지 않는지 확인** - -Run: `npx nx run-many -t=test 2>&1 | tail -20` -Expected: init.test.ts PASS (기존 테스트 영향 없음) - -**Step 3: Commit** - -```bash -git add tools/cli/open.ts -git commit -m "refactor: export ServerInfo helpers for testability" -``` - ---- - -## Task 2: readServerInfo / writeServerInfo 유닛 테스트 - -PID 파일 포맷의 읽기/쓰기 정확성을 검증한다. - -**Files:** -- Create: `tools/cli/__tests__/open.test.ts` - -**Step 1: 테스트 파일 작성** - -```typescript -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { - readServerInfo, - writeServerInfo, - getPidFilePath, - getVyncDir, - type ServerInfo, -} from '../open.js'; - -// readServerInfo/writeServerInfo는 모듈 상수 VYNC_DIR/PID_FILE을 사용하므로 -// 실제 ~/.vync/server.pid를 건드림. 테스트 전후에 백업/복원한다. -const REAL_PID_FILE = getPidFilePath(); -const REAL_VYNC_DIR = getVyncDir(); -let pidBackup: string | null = null; - -beforeEach(async () => { - // 기존 PID 파일 백업 - try { - pidBackup = await fs.readFile(REAL_PID_FILE, 'utf-8'); - } catch { - pidBackup = null; - } -}); - -afterEach(async () => { - // PID 파일 복원 - if (pidBackup !== null) { - await fs.writeFile(REAL_PID_FILE, pidBackup, 'utf-8'); - } else { - await fs.unlink(REAL_PID_FILE).catch(() => {}); - } -}); - -describe('writeServerInfo + readServerInfo', () => { - it('round-trips ServerInfo correctly', async () => { - const info: ServerInfo = { - pid: 12345, - mode: 'daemon', - filePath: '/tmp/test.vync', - }; - await writeServerInfo(info); - const result = await readServerInfo(); - expect(result).toEqual(info); - }); - - it('handles all three modes', async () => { - for (const mode of ['daemon', 'electron', 'foreground'] as const) { - await writeServerInfo({ pid: 99, mode, filePath: '/tmp/x.vync' }); - const result = await readServerInfo(); - expect(result?.mode).toBe(mode); - } - }); - - it('handles filePath with spaces', async () => { - const info: ServerInfo = { - pid: 42, - mode: 'daemon', - filePath: '/Users/test user/my project/plan.vync', - }; - await writeServerInfo(info); - const result = await readServerInfo(); - expect(result).toEqual(info); - }); -}); - -describe('readServerInfo edge cases', () => { - it('returns null when PID file does not exist', async () => { - await fs.unlink(REAL_PID_FILE).catch(() => {}); - expect(await readServerInfo()).toBeNull(); - }); - - it('returns null and cleans up old single-line PID format', async () => { - await fs.mkdir(REAL_VYNC_DIR, { recursive: true }); - await fs.writeFile(REAL_PID_FILE, '12345', 'utf-8'); - expect(await readServerInfo()).toBeNull(); - // PID file should be deleted - await expect(fs.access(REAL_PID_FILE)).rejects.toThrow(); - }); - - it('returns null and cleans up two-line PID format', async () => { - await fs.mkdir(REAL_VYNC_DIR, { recursive: true }); - await fs.writeFile(REAL_PID_FILE, '12345\ndaemon', 'utf-8'); - expect(await readServerInfo()).toBeNull(); - await expect(fs.access(REAL_PID_FILE)).rejects.toThrow(); - }); - - it('returns null for empty file', async () => { - await fs.mkdir(REAL_VYNC_DIR, { recursive: true }); - await fs.writeFile(REAL_PID_FILE, '', 'utf-8'); - expect(await readServerInfo()).toBeNull(); - }); -}); -``` - -**Step 2: 테스트 실행하여 실패 확인** - -Run: `npx vitest run tools/cli/__tests__/open.test.ts` -Expected: FAIL — `readServerInfo`가 아직 export되지 않았으면 import 에러, Task 1 완료 후면 PASS - -**Step 3: Task 1이 완료되었으면 테스트 통과 확인** - -Run: `npx vitest run tools/cli/__tests__/open.test.ts` -Expected: 모든 테스트 PASS - -**Step 4: Commit** - -```bash -git add tools/cli/__tests__/open.test.ts -git commit -m "test: add readServerInfo/writeServerInfo unit tests" -``` - ---- - -## Task 3: vyncStop 유닛 테스트 - -SIGTERM 전송, ESRCH 가드, PID 파일 삭제를 검증한다. 실제 프로세스를 spawn하여 종료 동작을 테스트한다. - -**Files:** -- Modify: `tools/cli/__tests__/open.test.ts` - -**Step 1: vyncStop 테스트 추가** - -```typescript -import { vyncStop } from '../open.js'; - -describe('vyncStop', () => { - it('handles missing PID file gracefully', async () => { - await fs.unlink(REAL_PID_FILE).catch(() => {}); - // Should not throw - await vyncStop(); - }); - - it('handles stale PID (process already gone)', async () => { - // Write a PID that doesn't exist (very high number) - await writeServerInfo({ - pid: 2147483647, - mode: 'daemon', - filePath: '/tmp/test.vync', - }); - await vyncStop(); - // PID file should be cleaned up - await expect(fs.access(REAL_PID_FILE)).rejects.toThrow(); - }); - - it('stops a real spawned process', async () => { - // Spawn a long-running sleep process - const { spawn } = await import('node:child_process'); - const child = spawn('sleep', ['60'], { detached: true, stdio: 'ignore' }); - const pid = child.pid!; - child.unref(); - - await writeServerInfo({ - pid, - mode: 'daemon', - filePath: '/tmp/test.vync', - }); - - await vyncStop(); - - // Process should be dead - expect(() => process.kill(pid, 0)).toThrow(); - // PID file should be gone - await expect(fs.access(REAL_PID_FILE)).rejects.toThrow(); - }); -}); -``` - -**Step 2: 테스트 실행** - -Run: `npx vitest run tools/cli/__tests__/open.test.ts` -Expected: 모든 테스트 PASS - -**Step 3: Commit** - -```bash -git add tools/cli/__tests__/open.test.ts -git commit -m "test: add vyncStop unit tests (stale PID, real process)" -``` - ---- - -## Task 4: 수동 E2E — Fresh start + PID 파일 검증 - -서버가 없는 상태에서 `vync open`이 정상 동작하고 PID 파일이 올바른 3줄 포맷으로 생성되는지 확인한다. - -**Files:** (수정 없음, 수동 검증) - -**Step 1: 기존 서버 정리** - -```bash -node bin/vync.js stop 2>/dev/null; rm -f ~/.vync/server.pid -``` - -**Step 2: 테스트 파일 생성** - -```bash -node bin/vync.js init /tmp/smart-restart-test.vync 2>/dev/null || true -``` - -**Step 3: Fresh start** - -```bash -node bin/vync.js open /tmp/smart-restart-test.vync -``` - -Expected: -- 서버 시작 로그 출력 -- 브라우저 열림 (또는 Electron 시작) - -**Step 4: PID 파일 검증** - -```bash -cat ~/.vync/server.pid -``` - -Expected: 3줄 출력 -``` - -daemon -/tmp/smart-restart-test.vync -``` - -(또는 Electron 빌드가 있으면 `electron` 모드) - -**Step 5: 결과 기록 후 서버 유지 (Task 5에서 사용)** - ---- - -## Task 5: 수동 E2E — Same-file 재실행 - -서버가 이미 같은 파일을 서빙 중일 때 `vync open`이 서버를 재시작하지 않고 브라우저만 여는지 확인한다. - -**Step 1: Task 4에서 서버가 실행 중인 상태에서 동일 파일로 open** - -```bash -node bin/vync.js open /tmp/smart-restart-test.vync -``` - -Expected 출력: -``` -[vync] Server already running, opening browser... -``` - -**Step 2: PID 변경 없음 확인** - -```bash -cat ~/.vync/server.pid -``` - -Expected: Task 4와 동일한 PID - ---- - -## Task 6: 수동 E2E — Different-file 자동 전환 - -서버가 다른 파일을 서빙 중일 때 `vync open`이 자동으로 stop → 새 파일로 시작하는지 확인한다. - -**Step 1: 두 번째 파일 생성** - -```bash -node bin/vync.js init /tmp/smart-restart-test2.vync 2>/dev/null || true -``` - -**Step 2: 다른 파일로 open** - -```bash -node bin/vync.js open /tmp/smart-restart-test2.vync -``` - -Expected 출력: -``` -[vync] Switching to: /tmp/smart-restart-test2.vync -[vync] Server stopped (PID ) -[vync] Server running at http://localhost:3100 (PID ) -``` - -**Step 3: PID 파일에 새 경로 확인** - -```bash -cat ~/.vync/server.pid -``` - -Expected: 새 PID + 새 파일 경로 - ---- - -## Task 7: 수동 E2E — Stale PID 처리 - -죽은 PID가 남아있을 때 `vync open`이 cleanup 후 정상 시작하는지 확인한다. - -**Step 1: 서버 종료** - -```bash -node bin/vync.js stop -``` - -**Step 2: 가짜 stale PID 파일 생성** - -```bash -mkdir -p ~/.vync -printf '99999\ndaemon\n/tmp/nonexistent.vync' > ~/.vync/server.pid -``` - -**Step 3: open 실행** - -```bash -node bin/vync.js open /tmp/smart-restart-test.vync -``` - -Expected: stale PID cleanup 후 정상 시작 (에러 없음) - -**Step 4: 정리** - -```bash -node bin/vync.js stop -``` - ---- - -## Task 8: 수동 E2E — vync stop SIGKILL 에스컬레이션은 불필요 - -정상 종료를 확인한다 (SIGKILL까지 가지 않는 것이 정상). - -**Step 1: 서버 시작** - -```bash -node bin/vync.js open /tmp/smart-restart-test.vync -``` - -**Step 2: stop 실행** - -```bash -node bin/vync.js stop -``` - -Expected: -- `[vync] Server stopped (PID )` 출력 -- "Force-killing" 메시지 없음 -- `~/.vync/server.pid` 삭제됨 - -**Step 3: 포트 확인** - -```bash -lsof -i :3100 -``` - -Expected: 출력 없음 (포트 해제 완료) - ---- - -## Task 9: 테스트 정리 + 최종 커밋 - -**Step 1: 전체 테스트 실행** - -Run: `npx vitest run tools/cli/__tests__/` -Expected: 모든 테스트 PASS - -**Step 2: E2E 테스트 잔여물 정리** - -```bash -rm -f /tmp/smart-restart-test.vync /tmp/smart-restart-test2.vync -node bin/vync.js stop 2>/dev/null || true -``` - -**Step 3: 최종 커밋 (필요 시)** - -```bash -git add -A -git commit -m "test: smart restart verification complete" -``` - ---- - -## 검증 매트릭스 - -| # | 시나리오 | 검증 방법 | Task | -|---|---------|----------|------| -| V1 | PID 파일 3줄 포맷 round-trip | 유닛 테스트 | 2 | -| V2 | Old 1줄 포맷 → null + cleanup | 유닛 테스트 | 2 | -| V3 | 공백 포함 경로 처리 | 유닛 테스트 | 2 | -| V4 | vyncStop stale PID 처리 | 유닛 테스트 | 3 | -| V5 | vyncStop 실제 프로세스 종료 | 유닛 테스트 | 3 | -| V6 | Fresh start + PID 파일 생성 | 수동 E2E | 4 | -| V7 | Same-file → 브라우저만 열기 | 수동 E2E | 5 | -| V8 | Different-file → 자동 전환 | 수동 E2E | 6 | -| V9 | Stale PID cleanup → 정상 시작 | 수동 E2E | 7 | -| V10 | vync stop 정상 종료 + 포트 해제 | 수동 E2E | 8 | diff --git a/docs/archive/2026-03-09-subagent-translator-design.md b/docs/archive/2026-03-09-subagent-translator-design.md deleted file mode 100644 index 984839e..0000000 --- a/docs/archive/2026-03-09-subagent-translator-design.md +++ /dev/null @@ -1,469 +0,0 @@ -# Vync Sub-agent 번역 레이어 설계 (v3 — Spike 결과 반영) - -> "생각이 보이는 대화" — Vync는 Claude Code와 유저 간의 이해도 동기화 채널(통역가) - -## 1. 문제 정의 - -### 현재 상태 - -`/vync-create` 실행 시 메인 세션(Claude Code)이 직접 .vync JSON을 다룬다: - -``` -메인 세션 context window: -├── vync-editing skill 로드 (SKILL.md + references) -├── JSON 스키마 이해 (PlaitElement[], mindmap, geometry...) -├── ID 생성 스크립트 실행 -├── 좌표 계산, 노드 배치 -├── JSON 구성 및 파일 쓰기 -└── → context window에 불필요한 기술적 세부사항 축적 -``` - -문제: -- **Context window 오염**: .vync JSON 구조, 참조 문서, 좌표 계산 등이 메인 대화 context를 차지 (2,000~5,000 토큰) -- **대화 흐름 단절**: 시각화 작업 중 대화의 맥락이 밀려남 -- **인지 부하**: 사용자도 Claude도 "대화"와 "시각화 작업"을 오가며 혼란 - -### 핵심 통찰 - -> "복잡한 현상, 다양한 제약 조건, 아키텍처 등을 텍스트로만 표현하기에는 한계가 있다. -> 해당 맥락에 대해 싱크를 맞추는 중간 도구가 되어야 한다. -> .vync 파일 자체를 쓰면 Claude Code도 이해하는 데 불필요한 노력이 필요하니, -> sub-agent가 중간 레이어로서 .vync로 번역을 해주는 역할을 두어 -> 메인 메모리는 사용하지 않도록 한다." - -### 기대 효과 - -| 항목 | 현재 | Sub-agent 도입 후 | -|------|------|-------------------| -| 메인 context 비용 | 2,000~5,000 토큰 | ~630 토큰 (3~8x 절감) | -| 대화 연속성 | 시각화 작업으로 단절 | prose 한 줄로 매끄러운 전환 | -| 관심사 분리 | 혼재 | 명확 (대화 vs JSON 조작) | - -## 2. 아키텍처 - -``` -┌─────────────────────────────────────────┐ -│ 사용자 ↔ Claude Code (메인 세션) │ -│ [대화 중심, prose만 교환] │ -│ │ -│ "이 아키텍처를 마인드맵으로 정리해줘" │ -│ → prose 구조 정리 → sub-agent 위임 │ -│ ← "mindmap: 프로젝트 > [기획, 개발]" │ -├─────────────────────────────────────────┤ -│ │ prose ⇅ prose │ -│ ┌──────┴──────────────────────────┐ │ -│ │ Vync Sub-agent (통역가) │ │ -│ │ - vync-editing skill 자동로드 │ │ -│ │ - .vync JSON 읽기/쓰기 │ │ -│ │ - ID 생성, 검증 │ │ -│ │ - 서버 열기 (vync open) │ │ -│ │ [별도 context, 메인 보호] │ │ -│ └──────┬──────────────────────────┘ │ -│ │ .vync JSON ⇅ │ -│ ┌──────┴──────────────────────────┐ │ -│ │ .vync 파일 ↔ 브라우저 │ │ -│ │ [시각적 캔버스, 실시간 동기화] │ │ -│ └─────────────────────────────────┘ │ -└─────────────────────────────────────────┘ -``` - -### 데이터 흐름 - -**생성 (Create):** -``` -사용자: "프로젝트 계획을 마인드맵으로 정리해줘" - → 메인 세션: 대화 맥락에서 구조 추출 → prose 정리 + 파일경로 해결 (절대경로) - → Agent tool: vync-translator spawn (prose + 타입 + 절대파일경로) - → sub-agent: skill 자동로드 → prose → .vync JSON 변환 → Write → 검증 → vync open - → sub-agent 반환: "mindmap: 프로젝트 > [기획, 개발, 출시]" - → 메인 세션: 한 줄 요약 전달, 대화 계속 -``` - -**읽기 (Read) — Diff-Aware:** -``` -사용자: "현재 마인드맵 확인해줘" (또는 "웹에서 수정했어, 뭐가 바뀌었는지 봐줘") - → 메인 세션: 파일경로 해결 → Agent tool: vync-translator spawn - → sub-agent: .vync 파일 Read → .lastread 스냅샷 확인 - → [스냅샷 있음] 현재 vs 스냅샷 비교 → diff 포함 요약 - → [스냅샷 없음] 구조 분석 (2단계 깊이) - → sub-agent 반환: "mindmap: 프로젝트 > [기획, 개발, +테스팅] (변경: 테스팅 추가)" - → .lastread 스냅샷 갱신 - → 메인 세션: prose + diff 전달, 대화 계속 -``` - -**업데이트 (Update):** -``` -사용자: "개발 아래에 테스트와 CI/CD 추가해줘" - → 메인 세션: 수정 지시 prose 정리 + 파일경로 해결 - → Agent tool: vync-translator spawn (지시 + 절대파일경로) - → sub-agent: 파일 Read → 노드 추가 → Write → 검증 → vync open - → sub-agent 반환: "updated: 개발 > [FE, BE, +테스트, +CI/CD]" - → 메인 세션: 한 줄 요약 전달, 대화 계속 -``` - -## 3. 커맨드 체계 - -### 통합 커맨드: `/vync` - -``` -/vync [args] -``` - -| Subcommand | Sub-agent | 설명 | -|------------|-----------|------| -| `init ` | ❌ | 빈 캔버스 생성 (단순 CLI) | -| `open ` | ❌ | 서버 시작 + 브라우저 열기 (단순 CLI) | -| `stop` | ❌ | 서버 종료 (단순 CLI) | -| `create ` | ✅ | prose → .vync 생성 + 서버 열기 | -| `read [file]` | ✅ | .vync → prose 번역 | -| `update ` | ✅ | 기존 .vync 점진적 편집 + 서버 열기 | - -### 호출 원칙 - -- `init`, `open`, `stop`: Bash로 직접 CLI 실행 (sub-agent 불필요) -- `create`, `read`, `update`: Agent tool로 vync-translator sub-agent 위임 (context window 보호) - -### 파일경로 해결 규칙 (메인 세션 책임) - -Sub-agent는 사용자에게 질문할 수 없으므로, 파일경로 해결은 반드시 메인 세션에서 수행: - -1. 사용자가 파일명을 지정한 경우 → 절대경로로 변환 -2. 현재 디렉토리에 .vync 파일이 하나만 있는 경우 → 그 파일 사용 -3. 여러 .vync 파일이 있는 경우 → 사용자에게 확인 -4. .vync 파일이 없는 경우 (create 시) → `vync init ` 먼저 실행 후 절대경로 전달 -5. **항상 절대경로**로 sub-agent에 전달 - -## 4. 커스텀 Sub-agent 정의 - -### 리뷰 반영: 에이전트 파일로 정의 - -기존 설계: `general-purpose` + 매번 긴 프롬프트 반복 (토큰 낭비) -개선: `.claude-plugin/agents/vync-translator.md` 에이전트 파일 생성 - -```markdown ---- -name: vync-translator -description: Vync 통역가 — prose ↔ .vync JSON 양방향 번역. 시각적 다이어그램 생성/읽기/수정. -tools: Read, Write, Edit, Bash, Glob, Grep -skills: vync-editing -model: sonnet -permissionMode: bypassPermissions ---- - -당신은 Vync 통역가입니다. -prose 구조를 .vync 파일(PlaitElement JSON)로 변환하거나, -.vync 파일을 읽고 prose로 요약하는 전문가입니다. - -## 핵심 규칙 - -1. **vync-editing skill이 자동 로드됩니다.** 참조 문서(references/)를 활용하세요. -2. **ID 생성**: `node ~/.claude/skills/vync-editing/scripts/generate-id.js ` -3. **검증**: Write/Edit 후 반드시 `node ~/.claude/skills/vync-editing/scripts/validate.js ` 실행. 에러 시 수정 후 재작성. (PostToolUse hook은 sub-agent에서 발동하지 않음) -4. **서버 열기**: 파일 작성 후 `node "$VYNC_HOME/bin/vync.js" open ` 실행 (idempotent). - -## 반환 포맷 - -**성공 시**: 한 줄 요약만 반환. 추가 설명 불필요. -- create: `"mindmap: 프로젝트 > [기획, 개발, 출시]"` -- read: `"mindmap: 프로젝트 > [기획(시장조사, 인터뷰), 개발(FE, BE)]"` -- update: `"updated: 개발 > [FE, BE, +테스트, +CI/CD]"` - -**실패 시**: `"error: <간략한 설명>"` 형식으로 반환. -예: `"error: 파일을 찾을 수 없습니다"`, `"error: JSON 검증 실패 — 중복 ID"` - -## 작업별 절차 - -### Create -1. 해당 타입의 참조 문서 Read (mindmap.md / geometry.md+arrow-line.md) -2. ID 생성 -3. PlaitElement[] JSON 구성 (skill 규칙 준수) -4. .vync 파일 Write (기존 파일 있으면 Read 후 merge) -5. `node validate.js ` 실행. 실패 시 수정 후 재작성. -6. 서버 열기 - -### Read -1. .vync 파일 Read -2. JSON 파싱하여 elements 분석 -3. 구조를 2단계 깊이까지 요약. 더 깊은 구조는 `...`로 표시. -4. 한 줄 요약 반환 - -### Update -1. .vync 파일 Read -2. 현재 구조 파악 -3. 지시에 따라 노드 추가/수정/삭제 - - 구조적 변경(이동/재배치): Write로 전체 교체 - - 텍스트 수정/노드 추가: Edit로 부분 수정 -4. `node validate.js ` 실행. 실패 시 수정 후 재작성. -5. 서버 열기 - -## Skill 로드 Fallback - -Skill tool이 사용 불가한 경우, 직접 Read: -- `~/.claude/skills/vync-editing/SKILL.md` -- `~/.claude/skills/vync-editing/references/mindmap.md` -- `~/.claude/skills/vync-editing/references/geometry.md` -- `~/.claude/skills/vync-editing/references/arrow-line.md` -``` - -### 장점 (vs general-purpose + 반복 프롬프트) - -| 항목 | general-purpose | 커스텀 에이전트 | -|------|----------------|----------------| -| 프롬프트 반복 | 매번 전체 프롬프트 전달 | 에이전트 파일에 한번 정의 | -| Skill 로드 | 프롬프트에서 수동 지시 | `skills: vync-editing` 자동 로드 | -| 도구 제한 | 모든 도구 사용 가능 | 필요한 도구만 명시적 허용 | -| 권한 모드 | 프롬프트에서 지시 | `permissionMode: bypassPermissions` | -| 유지보수 | 커맨드 파일에 산재 | 에이전트 파일 하나에 집중 | - -## 5. 메인 세션의 역할 - -메인 세션(커맨드 .md가 확장된 후)이 해야 할 것: - -### Create 시 - -1. **파일경로 해결**: §3 규칙에 따라 절대경로 확보. 파일 없으면 `vync init` 먼저 실행. -2. **대화 맥락에서 구조 추출**: 시각화할 내용을 구조화된 트리 prose로 정리 - - 대명사/참조 대신 구체적 내용 포함 - - 구조가 불명확하면 사용자에게 확인 (sub-agent에 위임하지 않음) -3. **Agent tool 호출**: `Agent(subagent_type="vync-translator")` — prose + 타입 + 절대경로 전달 -4. **결과 전달**: sub-agent의 한 줄 요약을 사용자에게 전달 -5. **대화 계속**: 시각화 작업의 세부사항 없이 대화 이어감 - -### Read 시 - -1. **파일경로 해결**: 절대경로 확보 -2. **Agent tool 호출**: `Agent(subagent_type="vync-translator")` — 파일경로 전달 -3. **결과 활용**: sub-agent의 prose 요약을 대화 맥락에 통합 -4. **대화 계속**: prose를 기반으로 논의 - -### Update 시 - -1. **파일경로 해결**: 절대경로 확보 -2. **수정 지시 정리**: 대화 맥락에서 수정할 내용을 자연어로 정리. 모호하면 사용자에게 확인. -3. **Agent tool 호출**: `Agent(subagent_type="vync-translator")` — 지시 + 절대경로 전달 -4. **결과 전달**: sub-agent의 변경 요약을 사용자에게 전달 - -### Prose 정리 가이드 (커맨드 .md에 포함) - -``` -구조를 정리할 때: -- 트리 형태의 인덴트된 목록 사용 -- 각 항목은 구체적인 이름/레이블 포함 -- 관계나 연결이 있으면 명시 (A → B) -- 대명사 대신 실제 내용 사용 -- 구조가 불명확하면 사용자에게 확인 후 sub-agent 호출 - -예시: -- 프로젝트 (root) - - 기획 - - 시장 조사 - - 사용자 인터뷰 - - 개발 - - 프론트엔드 (React) - - 백엔드 (Express) - - 출시 - - 마케팅 -``` - -## 6. Agent Tool 호출 사양 - -### Create - -```typescript -Agent({ - description: "Vync create {type}", - subagent_type: "vync-translator", - mode: "bypassPermissions", - prompt: ` - ## 작업: Create - 타입: {type} - 파일: {absolute_file_path} - - ## 구조 - {prose 트리} - ` -}) -``` - -### Read - -```typescript -Agent({ - description: "Vync read file", - subagent_type: "vync-translator", - prompt: ` - ## 작업: Read - 파일: {absolute_file_path} - ` -}) -``` - -### Update - -```typescript -Agent({ - description: "Vync update diagram", - subagent_type: "vync-translator", - mode: "bypassPermissions", - prompt: ` - ## 작업: Update - 파일: {absolute_file_path} - - ## 수정 지시 - {자연어 지시} - ` -}) -``` - -### 호출 시 주의사항 - -- Agent tool은 `allowed-tools` frontmatter에 명시적으로 나열되지 않으나, LLM이 항상 사용 가능 -- 커맨드 body에서 "Agent tool을 사용하라"고 지시하면 Claude가 자연스럽게 호출 -- `run_in_background: false` — 결과를 즉시 메인 세션에 반환해야 하므로 foreground - -## 7. 파일 변경 목록 - -### 신규 생성 - -| 파일 | 내용 | -|------|------| -| `.claude-plugin/agents/vync-translator.md` | 커스텀 sub-agent 정의 (§4) | - -### 수정 - -| 파일 | 변경 내용 | -|------|-----------| -| `.claude-plugin/commands/vync.md` | `create`, `update` 하위 커맨드 추가. `read`를 sub-agent 위임으로 변경. `allowed-tools` 확장. | -| `.claude-plugin/install.sh` | `vync-create.md` 제거 + deprecated 정리. `agents/` 심볼릭 링크 추가. | - -### 제거 - -| 파일 | 사유 | -|------|------| -| `.claude-plugin/commands/vync-create.md` | `/vync create`로 통합 | - -### 유지 - -| 파일 | 사유 | -|------|------| -| `.claude-plugin/skills/vync-editing/` | Sub-agent가 그대로 사용 | -| `.claude-plugin/hooks.json` | 메인 세션에서 여전히 작동. Sub-agent에서는 에이전트 시스템 프롬프트의 명시적 검증으로 대체 | -| `tools/cli/open.ts` | Layer 1에서 이미 완료 | - -## 8. Install Script 변경 - -```bash -# 기존: vync-create.md 심볼릭 링크 -for cmd in vync.md; do # vync-create.md 제거 - ... -done - -# deprecated vync-create 정리 -deprecated="$CLAUDE_DIR/commands/vync-create.md" -[ -L "$deprecated" ] && rm "$deprecated" && echo " [ok] Removed deprecated: /vync-create" -[ -f "$deprecated" ] && rm "$deprecated" && echo " [ok] Removed deprecated: /vync-create" - -# 에이전트 파일 심볼릭 링크 -agents_dir="$CLAUDE_DIR/agents" -mkdir -p "$agents_dir" -for agent in vync-translator.md; do - src="$PLUGIN_DIR/agents/$agent" - dst="$agents_dir/$agent" - [ -L "$dst" ] && rm "$dst" - ln -s "$src" "$dst" && echo " [ok] Agent: $agent" -done -``` - -## 9. 검증 계획 - -### 구현 전 검증 (Spike) — ✅ 완료 (2026-03-09) - -| # | 검증 대상 | 결과 | 비고 | -|---|----------|------|------| -| V1 | 커스텀 에이전트 인식 | ✅ PASS | `~/.claude/agents/`에 파일 존재 시 `subagent_type`으로 인식. **세션 시작 전 파일 존재 필요** | -| V2 | Read/Write/Bash 도구 | ✅ PASS | 3개 도구 모두 정상 동작 | -| V3 | Skill 자동 로드 | ✅ PASS | `skills: vync-editing` frontmatter가 커스텀 에이전트에서 작동. 파일 읽기 없이 skill 지식 보유 확인 | -| V4 | PostToolUse hook | ❌ FAIL | Sub-agent에서 hook 미발동. **Workaround**: 시스템 프롬프트에 명시적 `validate.js` 호출 지시 (§4 반영) | -| V5 | Prose 반환 + Context 보호 | ✅ PASS | Parent는 prose만 수신. JSON 결과는 격리됨 | - -**판정: GO** — V1+V2+V3 PASS, V4 workaround 적용, V5 PASS - -**주요 학습**: 에이전트 디스커버리는 세션 시작 시에만 실행. 세션 중 생성된 에이전트 파일은 다음 세션까지 인식 안 됨 → install.sh가 사전 설치하므로 실사용에 문제 없음 - -### 기능 검증 - -1. **Create flow**: `/vync create mindmap "프로젝트 계획"` → vync-translator spawn → 파일 생성 + 서버 열기 → 한 줄 요약 반환 -2. **Read flow**: `/vync read` → vync-translator spawn → 파일 읽기 → prose 요약 반환 -3. **Update flow**: `/vync update "개발 아래에 테스트 추가"` → vync-translator spawn → 파일 수정 → 변경 요약 반환 -4. **Context 보호**: create/read/update 후 메인 세션의 context에 .vync JSON이 남지 않음을 확인 -5. **대화 연속성**: 시각화 작업 전후로 대화 맥락이 유지됨을 확인 - -### Edge Cases - -| Case | 예상 동작 | -|------|-----------| -| 파일 없이 create | 메인 세션이 `vync init` 먼저 실행 후 sub-agent에 절대경로 전달 | -| 파일 없이 read | sub-agent 반환: `"error: 파일을 찾을 수 없습니다"` | -| 서버 이미 실행 중 | `vync open`이 idempotent (same-file: 브라우저만 열기) | -| 동일 이름 노드 여러 개 | sub-agent가 맥락 기반 추론 또는 가장 가까운 매치 | -| 대화 맥락 부족 | 메인 세션이 사용자에게 확인 후 sub-agent 호출 (sub-agent에 위임하지 않음) | -| Sub-agent 실패 | `"error: ..."` 반환 → 메인 세션이 사용자에게 안내 + 재시도 제안 | -| Sub-agent가 잘못된 파일 수정 | 절대경로 전달로 방지 (상대경로 사용 금지) | -| 동시 create 호출 | LWW(D-008) + 원자적 쓰기로 파일 안전. 단, 동시 호출은 권장하지 않음 | -| 큰 파일의 Read | 2단계 깊이까지 요약, `...`으로 생략 표시 | -| 서버 안 돌고 있을 때 update | Update 프롬프트에 `vync open` 포함 → 자동 서버 시작 | -| Skill tool 불가 | Fallback: SKILL.md + references 직접 Read | - -## 10. 향후 확장 (Layer 3) - -### "생각이 보이는 대화" 완성 - -Layer 2 완성 후 고려할 방향: - -- **대화 중 자동 업데이트**: 대화에서 합의된 결정사항이 자동으로 Vync에 반영 - - Claude가 "이 결정을 마인드맵에 반영할까요?"라고 제안 - - 사용자 승인 시 sub-agent로 업데이트 - -- **세션 간 맥락 브릿지**: .vync 파일이 세션 간 지식 전달 매체 - - 새 세션 시작 시 `/vync read`로 이전 맥락 복원 - - 대화 기반이 아닌 시각적 구조 기반의 맥락 복원 - -- **멀티 파일 지원**: 주제별로 다른 .vync 파일 관리 - - `architecture.vync`, `roadmap.vync`, `decisions.vync` - - `/vync read all` → 모든 파일의 요약 - -## 11. 설계 결정 근거 - -| 결정 | 근거 | 대안 | 변경 | -|------|------|------|------| -| ~~general-purpose sub-agent~~ → **커스텀 vync-translator** | Skill 자동 로드, 프롬프트 재사용, 토큰 절약 | general-purpose + 반복 프롬프트 | v2 변경 | -| /vync 통합 커맨드 | 진입점 하나가 직관적 | 별도 /vync-create, /vync-update — 분산 | 유지 | -| 한 줄 prose 반환 | 대화 흐름 유지 최우선 | 트리 구조 반환 — context 소모 | 유지 | -| bypassPermissions mode | create/update 시 Write/Edit 허용 필수 | 사용자에게 매번 허가 요청 — UX 저하 | 유지 | -| foreground 실행 | 결과를 즉시 대화에 사용해야 함 | background — 비동기 결과 처리 복잡 | 유지 | -| 기존 vync-editing skill 유지 | Sub-agent가 `skills:` frontmatter로 자동 활용 | skill 내용을 프롬프트에 인라인 | 유지 | -| 메인 세션에서 파일경로 해결 | Sub-agent는 AskUser 불가 | sub-agent에서 파일 탐색 — 실패 위험 | v2 추가 | -| 에러 반환 포맷 정의 | 실패 시 메인 context 오염 방지 | 자유 형식 에러 — 장황해질 위험 | v2 추가 | -| Read depth limit (2단계) | 큰 파일의 prose 폭발 방지 | 전체 구조 반환 — context 소모 | v2 추가 | -| model: sonnet | .vync 편집은 구조적 작업, Opus 불필요. 비용 ~5x 절감 | model: inherit (Opus) — 불필요한 비용 | v3 추가 | -| 명시적 validate.js 호출 | PostToolUse hook이 sub-agent에서 미발동 (Spike V4) | hook 의존 — sub-agent에서 작동 안 함 | v3 추가 | - -## 12. 의도적으로 제외한 항목 (리뷰 논의 후) - -| 항목 | 사유 | -|------|------| -| `context: fork` 커맨드 옵션 | /vync 통합 커맨드에서 subcommand별 fork 여부가 달라야 해서 적용 불가 | -| /vync delete 별도 커맨드 | `/vync update "X 노드 삭제"` 로 충분 | -| Sub-agent 중첩 호출 | Claude Code에서 sub-agent는 다른 sub-agent spawn 불가 | -| 동적 포트 할당 | 포트 3100 고정이 현재 아키텍처의 전제 | -| WebSocket graceful drain | LWW 정책으로 데이터 유실 위험 낮음 | - -## 13. 구현 순서 - -1. ~~**Spike (10분)**~~ ✅ 완료 — GO 판정 (2026-03-09) -2. **에이전트 파일**: `.claude-plugin/agents/vync-translator.md` 생성 -3. **커맨드 통합**: `.claude-plugin/commands/vync.md` 재설계 -4. **deprecated 제거**: `vync-create.md` 삭제 -5. **Install script**: 에이전트 링크 + deprecated 정리 -6. **E2E 검증**: create → read → update 전체 흐름 -7. **문서 업데이트**: D-013 등록, ARCHITECTURE.md, CLAUDE.md diff --git a/docs/archive/ARCHITECTURE_v0.md b/docs/archive/ARCHITECTURE_v0.md deleted file mode 100644 index 4de58d3..0000000 --- a/docs/archive/ARCHITECTURE_v0.md +++ /dev/null @@ -1,599 +0,0 @@ -# Vync 통합 아키텍처 설계서 - -> 4개 오픈소스 프로젝트(mcp_excalidraw, drawio-mcp, DeepDiagram, Drawnix) 분석을 기반으로 설계한 Vync의 최종 아키텍처 - ---- - -## 1. 4개 프로젝트 분석 요약 및 차용 전략 - -### 1.1 mcp_excalidraw (적합도: 95%) - -> GitHub: yctimlin/mcp_excalidraw — Excalidraw용 MCP 서버. 26개 Tool, MIT 라이선스. - -**차용하는 패턴:** - -| 패턴 | 설명 | Vync 적용 | -|------|------|-----------| -| 양방향 피드백 루프 | `describe_scene`(현재 상태 텍스트 설명) + `get_screenshot`(시각적 확인). AI가 "눈을 뜨고" 편집 | `vync_describe_scene` + `vync_get_screenshot` 도구로 구현 | -| Element-level CRUD | 전체 파일 교체 대신 요소 단위 add/update/delete. 정밀하고 안전 | `vync_add/update/delete_element` 도구로 구현 | -| 스냅샷 저장/복원 | 작업 상태를 명명된 스냅샷으로 저장, 롤백 가능 | `vync_snapshot` / `vync_restore_snapshot` | -| Tool 카테고리 분류 | 26개 tool을 CRUD/Layout/Awareness/IO/State로 체계적 분류 | Tier 1~3 구조로 점진 확장 | - -**아키텍처 참고 (stdio → HTTP → WebSocket → UI):** -``` -Claude Code (MCP Client) - │ stdio - ▼ -MCP Server (Node.js) - │ HTTP REST API - ▼ -Express Canvas Server (in-memory state + WebSocket) - │ WebSocket - ▼ -React + Excalidraw Frontend -``` - -**차용하지 않는 부분:** - -| 항목 | 이유 | -|------|------| -| Express 별도 서버 | Vync는 Next.js가 API + UI 통합 서빙 | -| 서버 메모리 기반 상태 관리 | Vync는 파일이 SSoT, 서버 메모리는 캐시일 뿐 | -| Excalidraw 네이티브 JSON 데이터 모델 | Vync는 PlaitElement[] 사용 | - ---- - -### 1.2 drawio-mcp (적합도: 60%) - -> GitHub: jgraph/drawio-mcp — Draw.io 공식 MCP 서버. 3개 Tool, Apache 2.0 라이선스. - -**차용하는 패턴:** - -| 패턴 | 설명 | Vync 적용 | -|------|------|-----------| -| "단순함 우선" 철학 | 3개 tool(xml/csv/mermaid)만으로 핵심 가치 전달 | Vync도 Tier 1 (6개)부터 시작하여 점진 확장 | -| 복수 입력 형식 수용 | XML, CSV, Mermaid 3가지 형식 지원 | Markdown, Mermaid, JSON 3가지 입력 경로 | - -**차용하지 않는 부분:** - -| 항목 | 이유 | -|------|------| -| 단방향 생성 (피드백 없음) | Vync는 양방향 피드백 루프 필수 | -| URL 기반 렌더링 (draw.io.com 의존) | Vync는 로컬 렌더링 | -| pako 압축 + Base64 인코딩 | 로컬 파일 기반에서 불필요 | - ---- - -### 1.3 DeepDiagram (패턴만 차용, 코드 0줄 — AGPL-3.0) - -> GitHub: twwch/DeepDiagram — AI + LangGraph 기반 다이어그램 생성. 1.1k stars, AGPL-3.0 라이선스. - -**기술 스택:** React 19 + Vite + Zustand (프론트) / Python 3.13 + FastAPI + LangGraph (백엔드) -**6개 Agent:** Mindmap(mind-elixir), Flow(React Flow), Mermaid, Charts(ECharts), DrawIO, Infographic(AntV) - -**차용하는 패턴 (클린룸 재구현):** - -| 패턴 | DeepDiagram 구현 | Vync 클린룸 구현 | -|------|----------------|-----------------| -| Router/Dispatcher | `dispatcher.py` — 3단계 의도 분류 (명시적 접두사 → 키워드 → LLM) | `router.ts` — TypeScript 순수 async 함수 | -| 다중 Agent 아키텍처 | `*_agent.py` — LangGraph agent | `*-agent.ts` — 순수 async 함수 | -| 컨텍스트 인식 라우팅 | 실행 히스토리 + 마지막 활성 agent 고려 | 동일 개념, 독립 구현 | -| AI 추론 가시성 | `` XML 태그로 추론 과정 스트리밍 | MCP tool 결과에 `description` 필드로 포함 | - -**핵심 파이프라인 패턴:** -``` -자연어 입력 → Router(의도 분류) → Agent 선택 → LLM 생성 → 중간 형식(Markdown/Mermaid/JSON) → 렌더러 -``` - -**차용하지 않는 부분:** - -| 항목 | 이유 | -|------|------| -| 모든 실제 코드 | AGPL-3.0 오염 방지 (Vync 라이선스 미정) | -| Python + FastAPI 백엔드 | Vync는 전체 TypeScript | -| LangGraph 프레임워크 | 순수 TypeScript async 함수로 경량 재구현 | -| Zustand + PostgreSQL 상태 관리 | Plait 내장 상태 + .vync 파일 | -| StreamingTagParser (XML 태그) | MCP tool 결과로 직접 반환, 스트리밍 불필요 | -| ECharts/Draw.io/Infographic Agent | Vync 범위 외 | - ---- - -### 1.4 Drawnix (UI 기반, MIT 라이선스) - -> GitHub: plait-board/drawnix — Plait 프레임워크 기반 화이트보드. Next.js + TypeScript. - -**차용하는 부분 (직접 사용, MIT):** - -| 항목 | 설명 | -|------|------| -| PlaitElement[] 데이터 모델 | `.vync` 파일 포맷의 기반. `{type, id, x, y, data, children}` | -| Drawnix React 컴포넌트 | 포크하여 저장소만 교체 (localforage → 파일 API) | -| onChange/onValueChange 이벤트 | 파일 동기화 트리거 포인트 | -| markdown-to-drawnix | Markdown → MindElement[] 변환 (npm 패키지) | -| mermaid-to-drawnix | Mermaid → DrawElement[] 변환 (npm 패키지) | -| Plait 플러그인 체계 | @plait/mind, @plait/draw 통합 | - -**Drawnix 핵심 데이터 구조:** - -```typescript -// .vync 파일 포맷 (Drawnix 호환 확장) -{ - type: "drawnix", - version: "0.0.2", - source: "vync", - elements: PlaitElement[], // 마인드맵, 플로우차트, 자유 캔버스 요소 - viewport: { x: number, y: number, zoom: number }, - theme: "light" | "dark" -} - -// MindElement (마인드맵 노드) -{ - id: string, - type: "mindmap", - data: { topic: string }, - children: MindElement[], - width: number, height: number, - isRoot?: boolean, - layout?: "right" | "left" | "indented", - fill?: string, strokeColor?: string -} - -// DrawElement (플로우차트 도형) -{ - id: string, - type: "rectangle" | "ellipse" | "diamond" | "text", - x: number, y: number, - width: number, height: number, - fill?: string, strokeColor?: string -} - -// ConnectorElement (연결선) -{ - id: string, - type: "connector", - data: { startId: string, endId: string } -} -``` - -**Drawnix 이벤트 시스템 (동기화 후킹 포인트):** - -```typescript - { /* 전체 변경 */ }} - onValueChange={(value: PlaitElement[]) => { /* 요소만 변경 */ }} - onViewportChange={(viewport: Viewport) => { /* 줌/팬 */ }} - onThemeChange={(theme: ThemeColorMode) => { /* 테마 */ }} - afterInit={(board: PlaitBoard) => { /* 초기화 완료 */ }} -/> -``` - -**포크 시 수정 사항 4가지:** -1. localforage 저장소 → 파일 API 호출로 교체 -2. WebSocket 리스너 추가 → 외부 변경 시 Plait board 갱신 -3. onChange 훅 → debounce(300ms) → PUT /api/file -4. /api/describe 엔드포인트 노출 - -**차용하지 않는 부분:** - -| 항목 | 이유 | -|------|------| -| localforage/IndexedDB 저장 | 파일 API 호출로 완전 교체 | -| 기존 Next.js 앱 구조 그대로 | WebSocket + 파일 감시 레이어 추가 필요 | - ---- - -## 2. 최종 아키텍처: 하이브리드 (파일 SSoT + MCP) - -### 2.1 아키텍처 선택 근거 - -| 기준 | 파일 기반만 | MCP 기반만 | 하이브리드 (선택) | -|------|-----------|-----------|-----------------| -| 파일 = SSoT 유지 | O | X | O | -| 어떤 에디터든 동작 | O | X | O | -| AI 피드백 루프 | X | O | O | -| 구조화된 AI 조작 | X | O | O | -| MCP 서버 없이도 동작 | O | X | O | - -**핵심 원칙:** -- **파일이 SSoT** — MCP 서버든, 웹 UI든, vim이든, 모두 같은 `.vync` 파일을 읽고 쓴다 -- **MCP는 optional enhancement** — MCP 없이도 파일 편집으로 동작. MCP는 구조화된 조작 + 피드백 루프 추가 -- **graceful degradation** — MCP 서버가 꺼져도 시스템은 정상 동작 - -### 2.2 아키텍처 다이어그램 - -``` -┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ Claude Code │ │ vim / VS Code │ │ 웹 브라우저 │ -│ ┌────────────┐ │ │ │ │ ┌────────────┐ │ -│ │ MCP Client │ │ │ 직접 파일 편집 │ │ │ Drawnix UI │ │ -│ └─────┬──────┘ │ │ │ │ └─────┬──────┘ │ -│ │ │ │ │ │ │ │ -│ 직접 편집도 가능 │ │ │ │ onChange │ -└────────┼─────────┘ └────────┬─────────┘ │ (debounce 300ms)│ - │ │ └────────┼─────────┘ - │ stdio │ │ - ┌─────▼──────────┐ │ ┌────────▼─────────┐ - │ Vync MCP Server│ │ │ Next.js API │ - │ (독립 프로세스) │ │ │ PUT /api/file │ - │ │ │ └────────┬─────────┘ - │ 14개 Tool │ │ │ - │ ├─ CRUD (6) │ │ │ - │ ├─ 변환 (2) │ │ │ - │ ├─ AI (3) │ │ │ - │ └─ 상태 (3) │ │ │ - └─────┬──────────┘ │ │ - │ │ │ - └─────────────────────┼───────────────────────┘ - │ - ┌──────────▼──────────┐ - │ .vync JSON 파일 │ - │ (Source of Truth) │ - └──────────┬──────────┘ - │ - ┌──────────▼──────────┐ - │ chokidar 파일 감시 │ - │ + echo prevention │ - │ (content hash) │ - └──────────┬──────────┘ - │ - ┌──────────▼──────────┐ - │ WebSocket Server │ - │ (변경 알림 방송) │ - └──────────┬──────────┘ - │ - ┌──────────▼──────────┐ - │ 웹 브라우저 │ - │ WS 수신 → board │ - │ PlaitElement[] 갱신 │ - └─────────────────────┘ -``` - -### 2.3 데이터 흐름 상세 - -**경로 1: AI → 파일 → UI (MCP 경로)** -``` -Claude Code → MCP Tool 호출 (stdio) - → Vync MCP Server가 .vync 파일 읽기/수정/쓰기 (atomic write) - → chokidar가 파일 변경 감지 - → WebSocket으로 브라우저에 알림 - → 프론트엔드가 새 PlaitElement[] 로드 → board 갱신 -``` - -**경로 2: AI → 파일 → UI (직접 편집 경로)** -``` -Claude Code → Write/Edit tool로 .vync 파일 직접 수정 - → chokidar가 파일 변경 감지 - → (이하 동일) -``` - -**경로 3: UI → 파일 (사용자 편집)** -``` -사용자가 웹 UI에서 노드 추가/이동/삭제 - → Drawnix onChange 이벤트 발생 - → debounce(300ms) 후 PUT /api/file - → 서버가 atomic write (tmp + rename) - → echo prevention: content hash로 자기 쓴 변경 무시 -``` - -**경로 4: 외부 에디터 → 파일 → UI** -``` -vim/VS Code에서 .vync 파일 편집 → 저장 - → chokidar가 파일 변경 감지 - → content hash 비교 → 실제 변경이면 WebSocket 알림 - → 프론트엔드가 새 데이터 로드 → board 갱신 -``` - ---- - -## 3. MCP Tool 정의 (Vync 전용) - -### Tier 1: 핵심 CRUD (Phase 2) - -| Tool | 입력 | 출력 | 설명 | -|------|------|------|------| -| `vync_read_file` | `{filePath}` | `{elements, viewport, theme, metadata}` | 파일 전체 읽기 | -| `vync_write_file` | `{filePath, elements, viewport?, theme?}` | `{success, elementCount}` | 파일 전체 쓰기 (교체) | -| `vync_describe_scene` | `{filePath}` | `{summary, diagramType, elementTree, stats}` | 장면 텍스트 설명 (피드백 루프 핵심) | -| `vync_add_element` | `{filePath, element, parentId?}` | `{success, elementId}` | 요소 추가 | -| `vync_update_element` | `{filePath, elementId, updates}` | `{success, previousState}` | 요소 수정 | -| `vync_delete_element` | `{filePath, elementId, deleteChildren?}` | `{success, deletedCount}` | 요소 삭제 | - -### Tier 2: 변환 도구 (Phase 2 후반) - -| Tool | 입력 | 출력 | 설명 | -|------|------|------|------| -| `vync_from_markdown` | `{filePath, markdown, mode: "replace"\|"append"}` | `{success, elementCount, rootId}` | Markdown → 마인드맵 | -| `vync_from_mermaid` | `{filePath, mermaid, mode: "replace"\|"append"}` | `{success, elementCount}` | Mermaid → 플로우차트 | - -### Tier 3: AI 강화 (Phase 3) - -| Tool | 입력 | 출력 | 설명 | -|------|------|------|------| -| `vync_generate` | `{filePath, instruction, diagramType?: "auto"\|"mindmap"\|"flow"\|"canvas"}` | `{success, elementCount, detectedType, description}` | 자연어 → 다이어그램 생성 | -| `vync_edit` | `{filePath, instruction}` | `{success, changes[]}` | 자연어로 기존 다이어그램 수정 | -| `vync_get_screenshot` | `{filePath, format?, width?, height?}` | `{imageData (base64)}` | 렌더링 스크린샷 | -| `vync_list_files` | `{directory?, recursive?}` | `{files[]}` | .vync 파일 목록 | -| `vync_snapshot` | `{filePath, snapshotName}` | `{success, snapshotPath}` | 스냅샷 저장 | -| `vync_restore_snapshot` | `{filePath, snapshotName}` | `{success}` | 스냅샷 복원 | - ---- - -## 4. AI 편집 파이프라인 (Phase 3, 클린룸 재구현) - -### 4.1 전체 파이프라인 - -``` -사용자/AI 입력 (자연어 명령) - │ - ┌────▼──────────────────────────────────┐ - │ Intent Router (router.ts) │ - │ │ - │ 1단계: 명시적 접두사 │ - │ @mindmap, @flow, @canvas, @edit │ - │ │ - │ 2단계: 키워드 휴리스틱 │ - │ "구조", "계층" → mindmap │ - │ "프로세스", "흐름" → flow │ - │ "수정", "변경", "삭제" → edit │ - │ │ - │ 3단계: LLM 의도 분류 (Anthropic API) │ - │ 컨텍스트: 실행 히스토리 + 현재 장면 │ - └────┬──────────────────────────────────┘ - │ - ┌────▼──────────────────────────────────┐ - │ Agent 분기 │ - ├──────────┬──────────┬────────┬────────┤ - │ Mindmap │ Flow │ Canvas │ Edit │ - │ Agent │ Agent │ Agent │ Agent │ - │ │ │ │ │ - │ LLM → │ LLM → │ LLM → │현재 요소│ - │ Markdown │ Mermaid │JSON 직접│+ 명령 │ - │ │ │ │ │ │ │→ patch │ - │ ▼ │ ▼ │ │ │ │ │ - │ md-to- │ mmd-to- │ │ │ │ │ - │ drawnix │ drawnix │ │ │ │ │ - └────┬─────┴────┬─────┴───┬────┴───┬────┘ - └──────────┴─────────┴────────┘ - │ - PlaitElement[] - │ - .vync 파일 저장 - │ - chokidar → WS → UI 갱신 -``` - -### 4.2 Agent별 역할 - -| Agent | 입력 예시 | 중간 형식 | 출력 | -|-------|---------|---------|------| -| MindmapAgent | "프로젝트 구조를 정리해줘" | Markdown (# / ## / - ) | MindElement[] | -| FlowAgent | "배포 프로세스를 그려줘" | Mermaid (flowchart TD) | DrawElement[] + ConnectorElement[] | -| CanvasAgent | "자유롭게 노트를 배치해줘" | 없음 (직접 생성) | PlaitElement[] | -| EditAgent | "3번 노드 이름을 바꿔줘" | JSON Patch | 수정된 PlaitElement[] | - -### 4.3 피드백 루프 - -``` -AI: vync_generate("프로젝트 구조를 마인드맵으로") - → 마인드맵 생성됨 (파일 저장, 브라우저 렌더링) - -AI: vync_describe_scene(filePath) - → "3개의 메인 브랜치를 가진 마인드맵. 총 15개 노드. 루트: '프로젝트 구조'..." - -AI: vync_edit("'백엔드' 브랜치에 'API 서버'와 '데이터베이스' 하위 노드 추가") - → 수정 완료, 17개 노드 - -AI: vync_get_screenshot(filePath) - → 렌더링 결과 시각적 확인 -``` - ---- - -## 5. 기술 스택 - -| 영역 | 선택 | 근거 | -|------|------|------| -| 언어 | TypeScript 전체 | 단일 언어, Drawnix/Plait 생태계 호환 | -| 프론트엔드 | Drawnix 포크 + Plait 플러그인 | MIT, 마인드맵+플로우차트+캔버스 통합 | -| 웹 서버 | Next.js 14+ | Drawnix 기반, API Routes 내장 | -| 서버→클라이언트 | WebSocket (ws) | 양방향, 멀티유저 확장 가능 | -| 파일 감시 | chokidar | Node.js 표준, 크로스 플랫폼 | -| MCP 서버 | @modelcontextprotocol/sdk (Node.js, stdio) | 공식 SDK | -| 변환기 | markdown-to-drawnix, mermaid-to-drawnix | MIT, Drawnix 생태계 | -| LLM | @anthropic-ai/sdk | Router 의도 분류 + Agent 생성 | -| 모노레포 | pnpm workspaces + turborepo | web, mcp-server, shared 분리 | -| 테스트 | Vitest | Vite 생태계, TS 네이티브 | - ---- - -## 6. 프로젝트 구조 - -``` -vync/ -├── packages/ -│ ├── web/ # Next.js (Drawnix 포크) -│ │ ├── app/ -│ │ │ ├── page.tsx -│ │ │ └── api/ -│ │ │ ├── file/route.ts # GET/PUT .vync 파일 -│ │ │ └── describe/route.ts # 장면 설명 API -│ │ ├── components/ -│ │ │ ├── VyncBoard.tsx # Plait board + 동기화 래퍼 -│ │ │ └── SyncIndicator.tsx # 동기화 상태 표시 -│ │ ├── lib/ -│ │ │ ├── file-sync.ts # 양방향 동기화 핵심 로직 -│ │ │ ├── echo-prevention.ts # content hash 기반 에코 방지 -│ │ │ ├── file-watcher.ts # chokidar + WS broadcast -│ │ │ └── atomic-write.ts # tmp + rename 원자적 쓰기 -│ │ └── hooks/ -│ │ └── useFileSync.ts # WebSocket 수신 React hook -│ │ -│ ├── mcp-server/ # MCP 서버 (독립 프로세스) -│ │ └── src/ -│ │ ├── index.ts # 진입점, stdio transport -│ │ ├── tools/ # 14개 도구 구현 -│ │ │ ├── read-file.ts -│ │ │ ├── write-file.ts -│ │ │ ├── describe-scene.ts -│ │ │ ├── add-element.ts -│ │ │ ├── update-element.ts -│ │ │ ├── delete-element.ts -│ │ │ ├── from-markdown.ts -│ │ │ ├── from-mermaid.ts -│ │ │ ├── generate.ts -│ │ │ ├── edit.ts -│ │ │ ├── get-screenshot.ts -│ │ │ ├── list-files.ts -│ │ │ ├── snapshot.ts -│ │ │ └── restore-snapshot.ts -│ │ └── agents/ # AI 파이프라인 (Phase 3) -│ │ ├── router.ts # 의도 분류 라우터 -│ │ ├── mindmap-agent.ts -│ │ ├── flow-agent.ts -│ │ ├── canvas-agent.ts -│ │ └── edit-agent.ts -│ │ -│ └── shared/ # 공유 타입 및 유틸리티 -│ └── src/ -│ ├── types/ -│ │ └── vync-file.ts # .vync 파일 포맷 타입 -│ ├── format/ -│ │ ├── reader.ts # 파일 읽기 -│ │ ├── writer.ts # 원자적 쓰기 -│ │ └── validator.ts # JSON 스키마 검증 -│ └── describe/ -│ └── scene-describer.ts # PlaitElement[] → 텍스트 설명 -│ -├── docs/ -│ ├── PLAN.md # 기존 계획서 -│ ├── ARCHITECTURE.md # 본 문서 -│ └── DECISIONS.md # 결정사항 + 미결 이슈 -├── package.json -├── pnpm-workspace.yaml -└── turbo.json -``` - ---- - -## 7. 단계별 구현 로드맵 (Phase 1~3) - -### Phase 1: 기반 + 양방향 파일 동기화 - -**목표**: Drawnix 포크가 .vync 파일 기반으로 동작하며 양방향 동기화 완성 - -| # | 작업 | 상세 | 의존 | -|---|------|------|------| -| 1.1 | 모노레포 설정 | pnpm workspace + turborepo, packages/web, packages/shared | — | -| 1.2 | .vync 파일 포맷 정의 | packages/shared에 타입, reader, writer, validator | 1.1 | -| 1.3 | Drawnix 포크 배치 | packages/web에 배치, 로컬 실행 확인 | 1.1 | -| 1.4 | localforage → API Route | GET/PUT /api/file 구현, localforage 제거 | 1.2, 1.3 | -| 1.5 | 파일 감시 + WebSocket | chokidar 감시 서비스 + ws WebSocket 서버 | 1.4 | -| 1.6 | WS 클라이언트 | 프론트엔드 WebSocket 수신 → Plait board 갱신 | 1.5 | -| 1.7 | 자동 저장 | onChange → debounce(300ms) → PUT /api/file | 1.4 | -| 1.8 | 에코 방지 | content hash 기반, 자기 쓴 변경 무시 | 1.5, 1.7 | -| 1.9 | 원자적 쓰기 | tmp + rename, JSON 파싱 실패 시 이전 상태 유지 | 1.4 | -| 1.10 | 동기화 상태 UI | SyncIndicator 컴포넌트 | 1.6 | - -**완료 기준:** -- [ ] .vync 파일의 다이어그램이 브라우저에 렌더링됨 -- [ ] 브라우저에서 편집 → 파일 자동 저장 -- [ ] vim으로 파일 수정 → 브라우저 3초 내 갱신 -- [ ] 에코 루프 발생하지 않음 - -### Phase 2: MCP 서버 + 핵심 도구 - -**목표**: Claude Code가 MCP 도구로 다이어그램을 읽고, 쓰고, 변환, 이해 - -| # | 작업 | 상세 | 의존 | -|---|------|------|------| -| 2.1 | MCP 서버 설정 | @modelcontextprotocol/sdk, stdio transport | 1.1 | -| 2.2 | Tier 1 CRUD | vync_read_file, vync_write_file | 1.2, 2.1 | -| 2.3 | 장면 설명 | vync_describe_scene (PlaitElement[] → 계층적 텍스트) | 2.2 | -| 2.4 | 요소 CRUD | vync_add/update/delete_element | 2.2 | -| 2.5 | Markdown 변환 | vync_from_markdown (markdown-to-drawnix 통합) | 2.2 | -| 2.6 | Mermaid 변환 | vync_from_mermaid (mermaid-to-drawnix 통합) | 2.2 | -| 2.7 | Claude Code 등록 | claude_desktop_config.json에 MCP 서버 추가 | 2.1 | -| 2.8 | E2E 검증 | MCP tool → 파일 생성 → 브라우저 렌더링 | 전체 | - -**완료 기준:** -- [ ] `vync_from_markdown` → 마인드맵 생성 → 브라우저에 나타남 -- [ ] `vync_describe_scene` → 다이어그램 텍스트 설명 반환 -- [ ] `vync_add_element` → 노드 추가 → 브라우저 반영 - -### Phase 3: AI 파이프라인 (클린룸 재구현) - -**목표**: 자연어로 다이어그램 생성/편집 가능 - -| # | 작업 | 상세 | 의존 | -|---|------|------|------| -| 3.1 | Intent Router | 3단계 의도 분류 (접두사 → 키워드 → LLM) | 2.1 | -| 3.2 | MindmapAgent | 명령 → LLM → Markdown → md-to-drawnix | 2.5, 3.1 | -| 3.3 | FlowAgent | 명령 → LLM → Mermaid → mmd-to-drawnix | 2.6, 3.1 | -| 3.4 | CanvasAgent | 명령 → LLM → PlaitElement[] 직접 생성 | 3.1 | -| 3.5 | EditAgent | 현재 장면 + 명령 → 수정된 PlaitElement[] | 2.3, 3.1 | -| 3.6 | vync_generate | Router+Agent를 내부 호출하는 MCP tool | 3.1~3.4 | -| 3.7 | vync_edit | EditAgent를 내부 호출하는 MCP tool | 3.5 | -| 3.8 | 상태 관리 도구 | vync_list_files, vync_snapshot, vync_restore_snapshot | 2.2 | -| 3.9 | 충돌 알림 UI | "외부에서 변경됨" 다이얼로그, 반영/무시 선택 | 1.6 | -| 3.10 | 스크린샷 | vync_get_screenshot (Puppeteer headless 캡처) | Phase 1 | - -**완료 기준:** -- [ ] "프로젝트 구조를 마인드맵으로" → 자동 마인드맵 생성 -- [ ] "3번 노드 이름을 바꿔줘" → 기존 다이어그램 수정 -- [ ] 전체 루프 3초 이내 반영 - ---- - -## 8. 핵심 기술 과제 - -### 8.1 에코 방지 (Echo Prevention) - -**문제**: 웹 → 파일 저장 → chokidar 감지 → WS → 웹 → 무한 루프 - -**해결**: Content hash 기반 -``` -1. 파일 쓰기 시 content의 SHA-256 해시를 기록 -2. chokidar가 변경 감지 시 새 파일의 해시 계산 -3. 저장된 해시와 동일하면 → 자기 쓴 변경 → 무시 -4. 다르면 → 외부 변경 → WebSocket 알림 -``` - -### 8.2 원자적 쓰기 (Atomic Write) - -``` -1. 임시 파일에 내용 쓰기: /path/to/.board.vync.json.tmp -2. JSON 유효성 검증 -3. rename()으로 원본 파일 교체 (POSIX atomic) -4. 실패 시 이전 파일 유지 -``` - -### 8.3 충돌 해결 - -PoC 단계: **Last Write Wins + 알림** -- 외부 변경 감지 시 "파일이 외부에서 변경되었습니다" 다이얼로그 -- 사용자가 "반영" 또는 "무시" 선택 - ---- - -## 9. 검증 방법 - -### Phase 1 검증 -1. `pnpm dev` → 브라우저에서 빈 캔버스 확인 -2. 브라우저에서 마인드맵 노드 추가 → `cat data/board.vync.json`으로 파일 저장 확인 -3. `vim data/board.vync.json`으로 노드 텍스트 수정 → 브라우저 자동 갱신 확인 -4. 빠르게 연속 편집 → 에코 루프 없음 확인 - -### Phase 2 검증 -1. Claude Code에서 `vync_from_markdown` 호출 → 마인드맵 파일 생성 → 브라우저 렌더링 확인 -2. `vync_describe_scene` 호출 → 현재 장면의 텍스트 설명 반환 확인 -3. `vync_add_element` → 노드 추가 → 파일 변경 → 브라우저 반영 확인 -4. `vync_from_mermaid` → 플로우차트 생성 확인 - -### Phase 3 검증 -1. `vync_generate("프로젝트 구조를 마인드맵으로 정리해줘")` → 자동 마인드맵 생성 -2. `vync_edit("3번 노드에 하위 항목 2개 추가해줘")` → 기존 다이어그램 수정 -3. `vync_describe_scene` → 수정 결과 텍스트 확인 (피드백 루프) -4. 전체 루프(자연어 → 파일 → 브라우저) 3초 이내 확인 diff --git a/docs/archive/DECISIONS_v0.md b/docs/archive/DECISIONS_v0.md deleted file mode 100644 index 7615ee6..0000000 --- a/docs/archive/DECISIONS_v0.md +++ /dev/null @@ -1,276 +0,0 @@ -# Vync MVP — 설계 결정서 - -> 2026-03-07 기획 세션에서 확정된 설계 결정사항. -> 이 문서는 "왜 이렇게 결정했는가"의 근거를 포함한다. - ---- - -## 1. 확정된 결정사항 - -### 1.1 프로젝트 범위: MVP (실사용 가능) - -**결정**: PoC가 아닌 MVP 수준. 실제로 Claude Code와 함께 계획 수립에 사용할 수 있는 수준. - -**포함 범위**: -- CLI 도구 (`vync init`, `vync open`) -- 안정적 양방향 파일 동기화 -- 기본 에러 핸들링 (JSON 파싱 실패, 파일 잠금 등) -- AI 편집 지원 도구 (CLAUDE.md, JSON Schema, 예시 파일) - -**제외 범위**: -- 패키징/배포 (`npx vync`로 설치 가능한 수준은 후속) -- 다중 사용자/원격 협업 -- 문서화 사이트 - -**근거**: 순수 PoC는 검증 후 버려지기 쉽고, 프로덕트 수준은 범위가 과도하다. "자기 자신이 실제로 쓸 수 있는 도구"가 적절한 목표. - ---- - -### 1.2 기반 전략: Drawnix 포크 + 수정 - -**결정**: Drawnix 저장소를 포크하여 파일 동기화 레이어를 추가한다. - -**대안 및 기각 사유**: -| 대안 | 기각 사유 | -|------|----------| -| Plait 직접 사용 (자체 앱) | UI(도구모음, 패널, 컨텍스트 메뉴 등)를 처음부터 구축해야 하여 개발 시간 2~3배 증가 | -| Drawnix를 컴포넌트로 임베드 | Drawnix가 외부 데이터 주입 API를 제공하는지 불확실, 통합 복잡성 높음 | - -**수정 범위**: -- 저장 메커니즘: localStorage/IndexedDB → 파일 시스템 (.vync JSON) -- WebSocket 레이어 추가 (실시간 파일 변경 감지) -- Custom Server 설정 (chokidar + WS) -- 불필요한 기능 제거 또는 비활성화 (클라우드 저장 등) - -**리스크**: -- 업스트림 변경 추적이 어려움 → 핵심 변경을 최소화하고 레이어로 분리 -- Drawnix가 초기 프로젝트라 불안정할 수 있음 → Phase 1에서 검증 후 진행 여부 결정 - ---- - -### 1.3 AI 편집 경로: PlaitElement[] JSON 직접 편집 - -**결정**: AI(Claude Code)는 .vync 파일의 PlaitElement[] JSON을 직접 읽고 수정한다. Markdown/Mermaid 변환 파이프라인은 MVP에서 제외. - -**대안 및 기각 사유**: -| 대안 | 기각 사유 | -|------|----------| -| 소스 파일 기반 (.md/.mmd → 자동 변환) | 웹 UI 편집을 소스에 역변환하는 것이 기술적으로 매우 어려움. 두 포맷 간 일관성 유지 부담 | -| 하이브리드 (초기 생성은 .md, 이후 수정은 JSON) | 복잡성 증가, AI에게 두 가지 모드를 안내해야 함 | - -**단일 경로의 장점**: -- 웹 UI 편집과 AI 편집이 동일한 포맷을 사용하여 양방향 완전 호환 -- 파일이 하나뿐이라 동기화 로직이 단순 -- 충돌 해결이 간단 (파일 하나의 Last Write Wins) - -**단일 경로의 도전**: -- PlaitElement[] JSON이 복잡하여 AI 편집 오류 가능성 → CLAUDE.md + JSON Schema + 예시로 완화 -- AI가 좌표계를 이해해야 함 → 가이드 문서에 좌표계 설명 포함 - ---- - -### 1.4 동기화 아키텍처: Next.js Custom Server - -**결정**: 단일 Next.js Custom Server 프로세스에 HTTP, WebSocket, chokidar를 통합. - -``` -vync open plan.vync - └─ Next.js Custom Server (:3000) - ├─ HTTP — 페이지 서빙 + API Routes (/api/sync) - ├─ WebSocket — 실시간 파일 변경 알림 - └─ chokidar — 파일 시스템 감시 -``` - -**근거**: -- 로컬 전용 도구이므로 서버리스/클라우드 배포 불필요 -- 단일 프로세스 = 단일 포트 = 단순한 사용자 경험 -- `vync open` 한 번으로 모든 것이 시작됨 - -**트레이드오프**: -- Next.js의 서버리스 최적화(ISR, Edge Functions 등) 사용 불가 → 로컬 도구에는 불필요 -- 프로세스가 죽으면 모든 것이 중단 → 로컬 도구에서는 수용 가능 - ---- - -### 1.5 파일 포맷: 래핑된 JSON - -**결정**: PlaitElement[] 배열을 메타데이터로 감싸는 구조. - -```json -{ - "version": 1, - "viewport": { "zoom": 1, "x": 0, "y": 0 }, - "elements": [ - { - "id": "abc123", - "type": "mindmap", - "data": { "topic": { "text": "Root" } }, - "children": [] - } - ] -} -``` - -**파일 확장자**: `.vync` - -**설계 근거**: -- `version`: 향후 포맷 마이그레이션 지원 -- `viewport`: 마지막 뷰 상태 복원 (줌, 스크롤 위치) -- `elements`: Drawnix/Plait 내부 데이터 모델과 직접 호환 -- 래핑 구조이므로 향후 메타데이터 확장 용이 (title, tags, createdAt 등) - -**대안 기각**: -- PlaitElement[] 네이키드 (메타데이터 없음): 버전 관리, 뷰포트 복원 불가 - ---- - -### 1.6 파일 관리 UX: CLI 중심 - -**결정**: 파일 관리는 CLI(`vync init`, `vync open`)로, 웹 UI는 순수 캔버스 에디터. - -```bash -$ vync init plan.vync # 빈 캔버스 파일 생성 -$ vync open plan.vync # 서버 시작 + 브라우저 열기 -``` - -**웹 UI에 없는 것**: 파일 목록, 사이드바, 프로젝트 탐색기. - -**근거**: -- 사용자가 이미 터미널에서 Claude Code와 작업 중이므로 CLI가 자연스러운 진입점 -- 웹 UI는 시각적 편집에만 집중하여 복잡도 최소화 -- Claude Code가 사용자에게 "vync open plan.vync로 확인하세요"라고 안내하기 쉬움 - ---- - -### 1.7 변경 알림 UX: 조용히 자동 반영 - -**결정**: 외부에서 파일이 변경되면 알림 없이 자동으로 캔버스를 업데이트. - -**근거**: -- Google Docs처럼 자연스러운 실시간 경험 -- AI가 파일을 수정할 때마다 "변경되었습니다" 팝업이 뜨면 방해됨 -- 사용자가 웹에서 편집 중이 아닐 때는 조용한 반영이 최선 - -**주의 사항** (구현 시 고려): -- 사용자가 웹에서 활발히 편집 중일 때 외부 변경이 오면, 편집 내용이 덮어쓰일 수 있음 -- 이 경우에 한해 미묘한 시각적 표시 (캔버스 테두리 깜빡임 등)를 추가할 수 있음 (MVP 후속) - ---- - -### 1.8 충돌 해결: Last Write Wins - -**결정**: 가장 마지막에 저장된 내용이 우선. 복잡한 머지 로직 없음. - -**에코 방지**: content hash 비교로 자체 쓰기를 감지하여 무한 루프 차단. - -**근거**: MVP에서는 단순함 우선. 실제 사용에서 충돌 빈도가 높으면 후속으로 개선. - ---- - -### 1.9 AI 편집 지원 도구 - -**결정**: CLAUDE.md 가이드 + JSON Schema + 예시 파일 모두 제공. - -| 도구 | 용도 | -|------|------| -| `CLAUDE.md` | .vync JSON 구조 설명, 편집 가이드, 좌표계, ID 생성 규칙 | -| `.vync.schema.json` | JSON Schema로 유효성 검증, 에디터 자동완성 | -| `examples/*.vync` | 마인드맵, 플로우차트 예시 파일 | - ---- - -### 1.10 패키지 매니저: pnpm - -**결정**: Drawnix가 pnpm workspace를 사용하므로 그대로 유지. - ---- - -## 2. 추후 고려 및 논의사항 - -### 2.1 Markdown/Mermaid 변환 파이프라인 (다음 버전) - -MVP에서 제외했지만, AI가 PlaitElement[] JSON을 직접 편집하는 것이 너무 어렵거나 오류가 많다면 재검토 필요. - -**옵션들**: -- `vync convert plan.md` CLI 명령어 추가 -- 서버 사이드 자동 변환 (`.md` 파일 감지 시) -- AI가 "의도"를 표현하면 시스템이 JSON patch 생성 - -**전제 조건**: Phase 1에서 PlaitElement[] JSON 구조를 분석한 후, AI 편집 난이도를 실제로 평가. - -### 2.2 웹 UI에서의 편집 → 소스 역변환 - -웹 UI에서 시각적으로 편집한 내용을 사람이 읽을 수 있는 형태(Markdown 등)로 역변환하는 것은 장기적으로 가치 있지만 기술적 난이도가 높다. - -- PlaitElement[] → Markdown: 레이아웃 정보(좌표, 스타일)가 손실됨 -- PlaitElement[] → Mermaid: 부분적으로 가능하지만 완전한 왕복 변환은 어려움 - -### 2.3 다중 파일 동시 편집 - -현재 설계는 `vync open ` 로 단일 파일을 여는 구조. 향후: -- 다중 탭 지원 (`vync open` 에 여러 파일 전달) -- 대시보드/파일 탐색기 UI 추가 -- 파일 간 링크/참조 - -### 2.4 충돌 해결 고도화 - -Last Write Wins를 넘어서: -- 외부 변경 알림 팝업 ("파일이 외부에서 변경되었습니다. 반영할까요?") -- 3-way merge (공통 조상 기준 양쪽 변경 머지) -- Operational Transform 또는 CRDT 기반 실시간 협업 - -### 2.5 배포/패키징 - -MVP 이후 다른 개발자도 사용할 수 있도록: -- `npx vync` / `bunx vync` 으로 즉시 실행 -- npm 패키지 발행 -- 글로벌 설치 지원 (`npm install -g vync`) - -### 2.6 보안 - -로컬 서버가 파일 시스템에 접근하므로: -- 접근 가능한 디렉토리를 프로젝트 루트로 제한 -- WebSocket 연결을 localhost로 제한 -- API 엔드포인트에 대한 접근 제어 - -### 2.7 Drawnix 업스트림 추적 - -포크 전략의 장기적 리스크: -- Drawnix 업스트림에 중요 버그 수정/기능이 추가될 때 머지 어려움 -- 완화: 변경을 최소화하고 레이어로 분리, 주기적으로 upstream diff 확인 -- 최악의 경우: Plait 라이브러리 직접 사용으로 전환 (Fallback 옵션 B) - -### 2.8 `vync watch` 명령어 - -파일 감시 데몬을 UI 없이 백그라운드로 실행하는 명령어. 자동 변환 파이프라인이 추가되면 유용. - -### 2.9 사용자 편집 중 외부 변경 처리 - -"조용히 자동 반영" 정책에서, 사용자가 활발히 편집 중일 때 외부 변경이 들어오면: -- 현재: Last Write Wins로 외부 변경이 사용자 편집을 덮어씀 -- 개선안: 편집 중인 요소와 외부 변경 요소가 겹치지 않으면 머지, 겹치면 사용자 편집 우선 - ---- - -## 3. 기술 스택 요약 - -| 레이어 | 기술 | -|--------|------| -| 프론트엔드 | Drawnix (Next.js + TypeScript + Plait + Tailwind CSS) | -| 서버 | Next.js Custom Server + ws (WebSocket) | -| 파일 감시 | chokidar | -| 파일 포맷 | .vync (JSON) | -| CLI | Node.js (bin 스크립트) | -| 패키지 매니저 | pnpm | - ---- - -## 4. 구현 Phase 요약 - -| Phase | 목표 | 핵심 산출물 | -|-------|------|------------| -| 1 | Drawnix 포크 + 데이터 모델 파악 | 포크된 저장소, PlaitElement[] 구조 문서화 | -| 2 | 파일 동기화 레이어 | Custom Server, chokidar, WebSocket, API Routes | -| 3 | 양방향 동기화 완성 | 에코 방지, 원자적 쓰기, 에러 핸들링 | -| 4 | CLI 도구 + AI 지원 | vync init/open, CLAUDE.md, JSON Schema, 예시 파일 | -| 5 | E2E 검증 | Claude Code ↔ 웹 UI 전체 루프 테스트 | diff --git a/docs/archive/PLAN_v0.md b/docs/archive/PLAN_v0.md deleted file mode 100644 index 30d999a..0000000 --- a/docs/archive/PLAN_v0.md +++ /dev/null @@ -1,210 +0,0 @@ -# Vync — Visual Sync PoC - -> 로컬 파일을 Single Source of Truth로 사용하여, 웹 UI에서의 시각적 편집과 외부 프로세스(AI/에디터 등)의 파일 수정이 양방향으로 실시간 동기화되는 시각적 계획 수립 도구 - -## 1. 프로젝트 개요 - -### 1.1 배경 - -AI(Claude Code 등)와 인간이 대화하면서 계획을 수립할 때, 텍스트만으로는 복잡한 구조를 이해하기 어렵다. 마인드맵, 플로우차트, 자유 캔버스 등 시각적 도구를 통해 **같은 파일을 바라보며** 이해도를 맞춰가는 워크플로우가 필요하다. - -### 1.2 핵심 컨셉 - -``` -┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ -│ Claude Code │ │ 로컬 파일 │ │ 웹 브라우저 │ -│ (파일 편집) │ ── Write/Edit ──→ │ .vync JSON │ ── chokidar ──→ │ Vync UI │ -│ │ │ (Source of Truth) │ │ (시각적 편집) │ -│ │ ←── Read ──────── │ │ ←── auto-save ── │ │ -└──────────────┘ └──────────────────┘ └──────────────┘ - ↕ WebSocket - ┌──────────────────┐ - │ Node.js 미들웨어 │ - │ (파일 감시 + WS) │ - └──────────────────┘ -``` - -**핵심 원칙:** -- **파일 = Source of Truth** — 어떤 프로세스든 파일만 수정하면 반영됨 -- **Claude Code 전용이 아님** — vim, VS Code, 스크립트 등 어떤 도구로든 파일 수정 시 반영 -- **양방향 동기화** — 웹→파일(auto-save), 파일→웹(auto-reload) - -### 1.3 지원할 시각화 유형 - -| 유형 | 용도 | AI 편집 경로 | -|------|------|-------------| -| 마인드맵 | 계층적 아이디어 정리, 브레인스토밍 | Markdown → markdown-to-drawnix | -| 플로우차트 | 프로세스 흐름, 의사결정 트리, 아키텍처 | Mermaid → mermaid-to-drawnix | -| 자유 캔버스 | 자유로운 노드 배치, 화이트보드 | PlaitElement[] JSON 직접 편집 | - ---- - -## 2. 기술 스택 분석: Drawnix - -### 2.1 Drawnix란 - -- **GitHub**: github.com/plait-board/drawnix -- **프레임워크**: Next.js + TypeScript (91.2%) -- **코어 엔진**: Plait 프레임워크 (Slate 영감의 드로잉 프레임워크) -- **스타일링**: Tailwind CSS -- **라이선스**: MIT - -### 2.2 Plait 프레임워크 아키텍처 - -Plait은 Slate(리치 텍스트 에디터)에서 영감을 받은 드로잉 프레임워크: -- **코어**: 기본 화이트보드 (줌, 패닝)만 제공 -- **플러그인 시스템**: 모든 비즈니스 기능은 플러그인으로 구현 - - `@plait/mind` — 마인드맵 (자동 레이아웃 알고리즘) - - `@plait/draw` — 플로우차트, 기본 도형, 프리핸드 - - `@plait/flow` — 프로세스 상태 시각화 - -### 2.3 데이터 모델 - -- **핵심 구조**: `PlaitElement[]` — JSON 배열 -- **변환 도구**: - - `markdown-to-drawnix`: Markdown → PlaitElement[] (마인드맵) - - `mermaid-to-drawnix`: Mermaid → PlaitElement[] (플로우차트) -- **local-first**: 현재 브라우저 캐시(localStorage/IndexedDB) 기반 저장 - -### 2.4 Drawnix 선택 이유 - -1. 마인드맵 + 플로우차트 + 자유 캔버스를 하나의 도구에서 통합 지원 -2. Next.js 기반이므로 서버 사이드 파일 동기화 레이어 추가가 자연스러움 -3. `markdown-to-drawnix`, `mermaid-to-drawnix`로 AI 편집 경로 확보 -4. local-first 철학이 파일 기반 동기화와 부합 -5. MIT 라이선스 오픈소스 - ---- - -## 3. 핵심 기술 과제 - -### 3.1 양방향 파일 동기화 - -**파일 → 웹 (외부 변경 감지)** -- chokidar로 파일 시스템 감시 -- 변경 감지 시 WebSocket으로 프론트엔드에 알림 -- 프론트엔드가 새 데이터를 로드하여 Plait board 업데이트 - -**웹 → 파일 (자동 저장)** -- Plait board의 onChange 이벤트 감지 -- 디바운싱(300~500ms)을 적용하여 서버로 전송 -- 서버가 파일에 원자적 쓰기(atomic write) - -### 3.2 에코 방지 (Echo Prevention) - -웹에서 변경 → 파일 저장 → chokidar가 감지 → 다시 웹으로 알림 → 무한 루프 문제 - -**해결 방안:** -- 서버가 "자신이 쓴 변경"인지 추적하는 플래그 사용 -- 파일 쓰기 직전에 감시 일시 중지, 쓰기 후 재개 -- 또는 content hash 비교로 실제 변경이 없으면 무시 - -### 3.3 충돌 해결 (Conflict Resolution) - -사용자가 웹 UI에서 편집 중에 AI가 파일을 수정하는 경우: - -**전략 (단순 → 복잡 순):** -1. **Last Write Wins** — 가장 단순. 마지막 저장이 우선 (PoC 단계) -2. **외부 변경 알림** — "파일이 외부에서 변경되었습니다. 반영할까요?" 다이얼로그 -3. **3-way merge** — 공통 조상 기준으로 양쪽 변경을 머지 (향후 고도화) - -PoC에서는 **Last Write Wins + 알림**으로 시작. - -### 3.4 AI 편집 전략 - -| 편집 수준 | 방법 | AI 난이도 | -|-----------|------|----------| -| 전체 재생성 | Markdown/Mermaid 작성 → 변환 도구로 JSON 생성 → 파일 교체 | 쉬움 | -| 구조만 수정 | PlaitElement[] JSON에서 노드 추가/삭제/이름변경 | 보통 | -| 부분 편집 + 레이아웃 유지 | JSON patch로 특정 요소만 수정, 좌표 유지 | 어려움 | - -**권장 워크플로우:** -1. AI가 Markdown/Mermaid 텍스트를 생성 -2. 변환 도구가 PlaitElement[] JSON으로 변환 -3. JSON 파일에 저장 -4. 웹 UI에서 사용자가 시각적으로 미세 조정 - -### 3.5 Hot Reload 안정성 - -- **디바운싱**: 파일 변경 이벤트가 여러 번 발생할 수 있으므로 300ms 디바운싱 -- **원자적 쓰기**: 임시 파일에 쓴 후 rename으로 교체 (파일이 반쯤 쓰인 상태 방지) -- **유효성 검증**: JSON 파싱 실패 시 이전 상태 유지 - ---- - -## 4. PoC 구현 계획 - -### Phase 1: 기반 구축 및 데이터 모델 파악 - -**목표**: Drawnix를 로컬에서 실행하고 데이터 모델을 완전히 이해한다. - -- [ ] 1.1 Drawnix 저장소 클론 및 로컬 실행 -- [ ] 1.2 PlaitElement[] JSON 구조 분석 (마인드맵, 플로우차트, 자유 캔버스 각각) -- [ ] 1.3 기존 저장/불러오기 메커니즘 코드 파악 -- [ ] 1.4 markdown-to-drawnix / mermaid-to-drawnix CLI 동작 확인 -- [ ] 1.5 데이터 모델 문서화 - -### Phase 2: 파일 동기화 레이어 - -**목표**: 파일 ↔ 웹 양방향 동기화의 기본 골격을 구현한다. - -- [ ] 2.1 Next.js API Route: 파일 읽기/쓰기 엔드포인트 (`/api/sync`) -- [ ] 2.2 chokidar 파일 감시 서비스 (Next.js 서버 사이드) -- [ ] 2.3 WebSocket 서버 (변경 알림 채널) -- [ ] 2.4 프론트엔드 WebSocket 클라이언트 → Plait board 업데이트 -- [ ] 2.5 에코 방지 메커니즘 구현 - -### Phase 3: 양방향 동기화 완성 - -**목표**: 안정적인 양방향 동기화를 완성한다. - -- [ ] 3.1 웹 UI onChange → 디바운싱 → API 호출 → 파일 저장 -- [ ] 3.2 외부 변경 감지 → content hash 비교 → 웹 UI 반영 -- [ ] 3.3 원자적 파일 쓰기 (tmp + rename) -- [ ] 3.4 간단한 충돌 알림 UI -- [ ] 3.5 에러 핸들링 (파일 잠금, 파싱 실패 등) - -### Phase 4: AI 편집 경로 검증 - -**목표**: Claude Code가 파일을 수정하면 웹 UI에 반영되는 전체 루프를 검증한다. - -- [ ] 4.1 Claude Code에서 Markdown 작성 → 마인드맵 자동 생성 테스트 -- [ ] 4.2 Claude Code에서 Mermaid 작성 → 플로우차트 자동 생성 테스트 -- [ ] 4.3 PlaitElement[] JSON 직접 편집 → 노드 추가/수정/삭제 테스트 -- [ ] 4.4 사용자가 웹에서 편집 → 파일 변경 → Claude Code가 읽기 테스트 -- [ ] 4.5 E2E 시나리오: AI-인간 협업 계획 수립 시뮬레이션 - ---- - -## 5. 리스크 및 완화 방안 - -| 리스크 | 영향 | 완화 방안 | -|--------|------|----------| -| Drawnix가 초기 프로젝트라 API 불안정 | 높음 | Plait 코어 API에 의존, Drawnix는 래퍼로만 활용. 최악의 경우 직접 React+Plait 앱 구축 | -| PlaitElement JSON이 AI가 편집하기 어려운 구조 | 중간 | markdown-to-drawnix/mermaid-to-drawnix 변환 경로를 주 편집 방식으로 사용 | -| 양방향 동기화 시 데이터 손실 | 높음 | 원자적 쓰기 + 백업 파일 유지 + content hash 검증 | -| 웹 UI에서의 시각적 편집이 Drawnix에서 제한적 | 중간 | Phase 1에서 Drawnix의 편집 능력을 먼저 검증 후 진행 여부 결정 | -| Next.js 서버에서 chokidar 사용 시 성능/호환성 문제 | 낮음 | 별도 Node.js 프로세스로 분리 가능 | - ---- - -## 6. 대안 경로 (Fallback) - -Drawnix가 요구사항을 충족하지 못할 경우: - -1. **Excalidraw + 커스텀 파일 동기화**: 자유 캔버스 위주. AI 편집은 전체 재생성 방식 -2. **다중 렌더러 통합**: Markmap(마인드맵) + Mermaid(플로우차트) + Excalidraw(캔버스)를 탭으로 전환 -3. **직접 구축**: React Flow + D3 + chokidar + WebSocket으로 맞춤형 도구 빌드 - ---- - -## 7. 성공 기준 - -PoC가 성공했다고 판단하는 기준: - -- [ ] 로컬 파일(.vync JSON)을 열면 웹 UI에 마인드맵/플로우차트가 렌더링됨 -- [ ] 웹 UI에서 노드를 추가/이동/삭제하면 로컬 파일이 자동 저장됨 -- [ ] 외부에서(vim, Claude Code 등) 파일을 수정하면 웹 UI가 자동 갱신됨 -- [ ] Claude Code가 Markdown을 작성하면 마인드맵이 자동 생성됨 -- [ ] Claude Code가 Mermaid를 작성하면 플로우차트가 자동 생성됨 -- [ ] 전체 루프가 3초 이내에 반영됨 diff --git a/docs/archive/RESEARCH-4-PROJECTS.md b/docs/archive/RESEARCH-4-PROJECTS.md deleted file mode 100644 index b289a26..0000000 --- a/docs/archive/RESEARCH-4-PROJECTS.md +++ /dev/null @@ -1,675 +0,0 @@ -# Vync 오픈소스 프로젝트 분석 및 통합 전략 - -> 2026-03-07 — 4개 오픈소스 프로젝트(mcp_excalidraw, drawio-mcp, DeepDiagram, Drawnix) 심층 분석 결과 -> 각 프로젝트의 장단점, Vync에 차용할 패턴, 통합 아키텍처 설계 - ---- - -## 1. 분석 대상 프로젝트 개요 - -| 프로젝트 | GitHub | 핵심 성격 | 라이선스 | Stars | -|----------|--------|---------|---------|-------| -| mcp_excalidraw | yctimlin/mcp_excalidraw | Excalidraw용 MCP 서버 (26개 Tool) | MIT | 커뮤니티 | -| drawio-mcp | jgraph/drawio-mcp | Draw.io 공식 MCP 서버 (3개 Tool) | Apache 2.0 | 1,000+ | -| DeepDiagram | twwch/DeepDiagram | AI+LangGraph 기반 다이어그램 생성 | **AGPL-3.0** | 1,100+ | -| Drawnix | plait-board/drawnix | Plait 프레임워크 기반 화이트보드 | MIT | — | - ---- - -## 2. mcp_excalidraw 상세 분석 - -### 2.1 아키텍처 - -``` -Claude Code (MCP Client) - │ stdio - ▼ -MCP Server (Node.js, 26개 Tool) - │ HTTP REST API - ▼ -Express Canvas Server (Port 3000, in-memory state) - │ WebSocket - ▼ -React + Excalidraw Frontend (실시간 렌더링) -``` - -### 2.2 MCP Tool 전체 목록 (26개) - -| 카테고리 | Tool | 역할 | -|---------|------|------| -| **Element CRUD (7)** | create_element | 새 도형/텍스트 생성 | -| | get_element | 특정 요소 조회 | -| | update_element | 요소 속성 수정 | -| | delete_element | 요소 삭제 | -| | query_elements | 조건으로 요소 검색 | -| | list_elements | 전체 요소 목록 | -| | batch_update_elements | 일괄 수정 | -| **Layout (6)** | align_elements | 정렬 (좌/우/중앙/상/하) | -| | distribute_elements | 균등 배치 | -| | group_elements | 그룹 생성 | -| | ungroup_elements | 그룹 해제 | -| | lock_elements | 요소 잠금 | -| | set_z_index | 레이어 순서 | -| **Scene Awareness (2)** | describe_scene | 캔버스 현재 상태 텍스트 설명 | -| | get_canvas_screenshot | PNG 스크린샷 캡처 | -| **File I/O (5)** | export_scene | .excalidraw JSON 생성 | -| | import_scene | 파일에서 로드 | -| | export_to_image | PNG/SVG/PDF 내보내기 | -| | create_from_mermaid | Mermaid → Excalidraw 변환 | -| | save_diagram | 저장소에 저장 | -| **State Mgmt (3)** | clear_canvas | 전체 초기화 | -| | snapshot_scene | 스냅샷 저장 | -| | restore_snapshot | 스냅샷 복원 | -| **Viewport (1)** | set_viewport | 확대/축소, 패닝 | -| **Reference (2)** | read_diagram_guide | 디자인 가이드 | -| | get_resource | 리소스 조회 | - -### 2.3 데이터 모델 - -```typescript -// Excalidraw Element -{ - id: string; - type: "rectangle" | "diamond" | "ellipse" | "arrow" | "text" | ...; - x: number; y: number; - width: number; height: number; - strokeColor: string; - backgroundColor: string; - fillStyle: "hachure" | "cross-hatch" | "solid"; - text?: string; - groupIds: string[]; - locked: boolean; -} - -// Scene -{ - elements: Element[]; - appState: { viewBackgroundColor, zoom, scrollX, scrollY } -} -``` - -### 2.4 장점 - -- **양방향 피드백 루프**: `describe_scene`으로 AI가 현재 상태를 이해하고, `get_canvas_screenshot`으로 시각적 확인. AI가 "눈을 뜨고" 편집하는 결정적 차이 -- **세밀한 제어**: Element-level CRUD로 전체 파일 교체 없이 정밀 조작 -- **실시간 동기화**: WebSocket으로 즉각 피드백 -- **상태 관리**: 스냅샷으로 롤백 가능 -- **zod 기반 입력 검증**: 견고한 에러 핸들링 - -### 2.5 단점 - -- 서버 재시작 시 상태 손실 (메모리 기반) -- 인증 없음 (로컬 only 권장) -- Express 별도 서버 필요 (프로세스 2개) - -### 2.6 Vync 차용 전략 - -| 차용 O | 차용 X | -|--------|--------| -| 피드백 루프 패턴 (describe_scene + screenshot) | Express 별도 서버 (Next.js 통합) | -| Element-level CRUD 도구 설계 | 서버 메모리 기반 상태 (파일이 SSoT) | -| 스냅샷 저장/복원 | Excalidraw 데이터 모델 (PlaitElement[] 사용) | -| Tool 카테고리 분류 체계 | | - ---- - -## 3. drawio-mcp 상세 분석 - -### 3.1 아키텍처 - -``` -Claude / Claude Desktop - │ stdio - ▼ -MCP Server (3개 Tool) - │ pako 압축 + Base64 인코딩 - ▼ -draw.io URL 생성 → 사용자가 브라우저에서 열기 -``` - -### 3.2 MCP Tool 전체 목록 (3개) - -| Tool | 파라미터 | 설명 | -|------|---------|------| -| open_drawio_xml | xml, lightbox?, darkMode? | Draw.io XML로 다이어그램 열기 | -| open_drawio_csv | csv, lightbox?, darkMode? | 테이블 데이터 → 다이어그램 변환 | -| open_drawio_mermaid | mermaid, lightbox?, darkMode? | Mermaid → 다이어그램 변환 | - -### 3.3 데이터 모델 - -```xml - - - - - - - - - - - - -``` - -### 3.4 장점 - -- **극도의 간결함**: 3개 tool만으로 핵심 가치 전달 -- **검증된 공식 프로젝트**: jgraph(draw.io 개발사) 공식 유지 -- **복수 입력 형식**: XML, CSV, Mermaid 3가지 지원 - -### 3.5 단점 - -- **단방향**: 생성만 가능, 피드백 루프 없음 -- **편집 불가**: 사용자가 수동으로 draw.io에서 편집 -- **외부 의존**: draw.io.com 접근 필요 - -### 3.6 Vync 차용 전략 - -| 차용 O | 차용 X | -|--------|--------| -| "단순함 우선" 철학 (핵심부터 점진 확장) | 단방향 생성 | -| 복수 입력 형식 수용 | URL 기반 렌더링 | -| | pako 압축 | - ---- - -## 4. DeepDiagram 상세 분석 - -### 4.1 기술 스택 - -- **프론트엔드**: React 19 + Vite + Zustand -- **렌더러 6개**: React Flow, mind-elixir, Mermaid.js, ECharts, AntV, Draw.io -- **백엔드**: Python 3.13 + FastAPI + LangGraph 1.0.4 -- **DB**: PostgreSQL 16 + SQLModel -- **인프라**: Docker Compose + Nginx - -### 4.2 AI Agent 파이프라인 - -``` -사용자 입력 (텍스트/이미지/문서 + LLM 설정) - │ - ▼ -Router (dispatcher.py) -├─ 명시적 @agent 라우팅 -├─ LLM 의도 분류 -└─ 컨텍스트 인식 (실행 히스토리 + 마지막 활성 agent) - │ - ├── Mindmap Agent → Markdown (#/##/###/-) - ├── Flow Agent → JSON ({nodes[], edges[]}) - ├── Mermaid Agent → Mermaid 문법 - ├── Charts Agent → ECharts JSON - ├── DrawIO Agent → mxGraph XML - └── Infographic Agent → AntV DSL (2단계: 템플릿 선택 → 코드 생성) - │ - ▼ -XML Tag Output (Tool 호출 없이 직접 출력) -├─ AI 설계 의도 -└─ 다이어그램 코드 - │ - ▼ -StreamingTagParser (상태 머신: INIT → DESIGN_CONCEPT → CODE → DONE) - │ - ▼ -SSE 스트리밍 → 프론트엔드 렌더링 -``` - -### 4.3 Router 의도 분류 상세 - -```python -# 3단계 분류 -1. 명시적 라우팅: @mindmap, @flow, @mermaid, @charts, @drawio, @infographic -2. LLM 의도 분류: - - mindmap: 계층적 구조에 적합 - - flow: 표준 플로우차트에만 적합 - - mermaid: 시퀀스, 클래스, 상태 다이어그램 - - charts: 정량적 데이터 - - drawio: 아키텍처, 복잡한 UML - - infographic: 인포그래픽, 데이터 포스터 -3. 컨텍스트 인식: LAST_ACTIVE_AGENT + EXECUTION_HISTORY 고려 -``` - -### 4.4 Agent 구현 패턴 - -```python -# 각 Agent의 공통 패턴 -async def agent_node(state: AgentState): - messages = state['messages'] - current_code = extract_current_code_from_messages(messages) # 기존 코드 추출 - - system_content = AGENT_SYSTEM_PROMPT + get_thinking_instructions() - if current_code: - system_content += f"\n\nCURRENT CODE:\n{current_code}" - - llm = get_configured_llm(state) # 모델 교체 가능 - response = await llm.astream([system_prompt] + messages) - return {"messages": [response]} -``` - -### 4.5 장점 - -- **의도 분류 정확도**: 3단계 분류 + 컨텍스트 인식 -- **다중 렌더러**: 6종 다이어그램 유형 지원 -- **AI 추론 가시성**: `` 태그로 실시간 스트리밍 -- **모델 유연성**: OpenAI, DeepSeek, 커스텀 API 지원 -- **Extended Thinking**: `get_thinking_instructions()`로 깊은 추론 - -### 4.6 단점 - -- **AGPL-3.0**: 파생물 오픈소스 강제. 네트워크 서비스도 적용 -- **Python 백엔드**: Vync(TypeScript)와 언어 불일치 -- **PostgreSQL 의존**: 로컬 도구에는 과도한 인프라 -- **LangGraph 의존**: 프레임워크 락인 - -### 4.7 Vync 차용 전략 (클린룸 재구현, 코드 0줄) - -| 차용 (패턴만) | 차용 X | -|--------------|--------| -| Router/Dispatcher 패턴 (3단계 의도 분류) | 모든 실제 코드 (AGPL 오염 방지) | -| 다중 Agent 아키텍처 | Python + FastAPI | -| 컨텍스트 인식 라우팅 | LangGraph 프레임워크 | -| AI 추론 가시성 개념 | Zustand + PostgreSQL | -| | ECharts/DrawIO/Infographic Agent | - -**클린룸 매핑:** - -| DeepDiagram | Vync (독립 구현) | -|------------|-----------------| -| dispatcher.py (Python, LangGraph) | router.ts (TypeScript, 순수 async) | -| *_agent.py (LangGraph agent) | *-agent.ts (순수 async 함수) | -| StreamingTagParser (XML tag) | 불필요 (MCP tool 결과로 직접 반환) | -| Zustand + PostgreSQL | Plait 내장 상태 + .vync 파일 | - ---- - -## 5. Drawnix 상세 분석 - -### 5.1 프로젝트 구조 - -``` -drawnix/ -├── apps/web/src/app/app.tsx # 메인 App -├── packages/ -│ ├── drawnix/src/ -│ │ ├── drawnix.tsx # Drawnix React 컴포넌트 -│ │ ├── data/ -│ │ │ ├── json.ts # JSON 직렬화/역직렬화 -│ │ │ ├── filesystem.ts # 파일 시스템 접근 -│ │ │ └── types.ts -│ │ ├── plugins/ # Plait 플러그인 -│ │ ├── transforms/ # 요소 변환 -│ │ ├── hooks/ # React 훅 -│ │ └── components/ # UI 컴포넌트 -│ ├── react-board/ # React 보드 래퍼 -│ └── react-text/ # 텍스트 렌더링 -├── markdown-to-drawnix/ # 별도 저장소 -└── mermaid-to-drawnix/ # 별도 저장소 -``` - -### 5.2 핵심 의존성 - -```json -{ - "@plait-board/markdown-to-drawnix": "^0.0.8", - "@plait-board/mermaid-to-drawnix": "^0.0.7", - "@plait/common": "^0.92.1", - "@plait/core": "^0.92.1", - "@plait/draw": "^0.92.1", - "@plait/layouts": "^0.92.1", - "@plait/mind": "^0.92.1", - "@plait/text-plugins": "^0.92.1", - "localforage": "^1.10.0", - "react": "19.2.0", - "slate": "^0.116.0" -} -``` - -### 5.3 PlaitElement[] 데이터 모델 - -**기본 PlaitElement:** -```typescript -{ - id: string; // UUID - type: string; // 'mindmap' | 'rectangle' | 'connector' | ... - x: number; - y: number; - angle: number; - data: any; - children: PlaitElement[]; -} -``` - -**MindElement (마인드맵 노드):** -```typescript -{ - id: "uuid-1", - type: "mindmap", - data: { topic: "노드 텍스트" }, - children: [/* 자식 노드 */], - width: 200, height: 50, - isRoot: true, - layout: "right", // "right" | "left" | "indented" - fill: "#ffffff", - strokeColor: "#000000", - branchColor: "#333", - branchShape: "bight", // "bight" | "polyline" - isCollapsed: false, - points: [[0, 0]] // 루트 노드 뷰포트 포인트 -} -``` - -**DrawElement (플로우차트 도형):** -```typescript -{ - id: "rect-1", - type: "rectangle", // "rectangle" | "ellipse" | "diamond" | "text" - x: 100, y: 100, - width: 200, height: 100, - fill: "#e3f2fd", - strokeColor: "#1976d2", - strokeWidth: 2, - rough: false // 손그린 스타일 -} -``` - -**ConnectorElement (연결선):** -```typescript -{ - id: "conn-1", - type: "connector", - data: { - startId: "rect-1", - endId: "rect-2", - startSocketIndex: 2, - endSocketIndex: 0 - }, - strokeColor: "#333" -} -``` - -**.vync 파일 전체 구조:** -```json -{ - "type": "drawnix", - "version": "0.0.2", - "source": "vync", - "elements": [/* PlaitElement[] */], - "viewport": { "x": 0, "y": 0, "zoom": 1 }, - "theme": "light" -} -``` - -### 5.4 저장/로드 메커니즘 - -**현재 (localforage 기반):** -```typescript -// 저장 -localforage.config({ name: 'Drawnix', storeName: 'drawnix_store' }); -localforage.setItem('main_board_content', { children, viewport, theme }); - -// 직렬화 -const serializeAsJSON = (board: PlaitBoard): string => { - return JSON.stringify({ - type: 'drawnix', version: '0.0.2', - elements: board.children, viewport: board.viewport, theme: board.theme - }, null, 2); -}; -``` - -### 5.5 이벤트 시스템 (동기화 후킹 포인트) - -```typescript - {}} // 전체 변경 - onValueChange={(value: PlaitElement[]) => {}} // 요소만 - onViewportChange={(viewport: Viewport) => {}} // 줌/팬 - onThemeChange={(theme: ThemeColorMode) => {}} // 테마 - afterInit={(board: PlaitBoard) => {}} // 초기화 완료 -/> -``` - -### 5.6 변환기 - -**markdown-to-drawnix:** -```typescript -parseMarkdownToDrawnix(markdown: string, mainTopic?: string): PlaitMind -// remark-parse → AST → Heading depth 기반 계층 → MindElement[] -// 입력: # 제목 / ## 하위 / - 항목 -// 출력: PlaitMind (MindElement 트리) -``` - -**mermaid-to-drawnix:** -```typescript -parseMermaidToDrawnix(definition: string, config?: MermaidConfig) - : Promise<{elements: PlaitElement[], files?: any[]}> -// Mermaid 파싱 → 노드→DrawElement, 엣지→ConnectorElement → 자동 레이아웃 -// 지원: flowchart, sequence, gantt, state, class diagram -``` - -### 5.7 장점 - -- 마인드맵+플로우차트+캔버스 통합 -- PlaitElement[] JSON이 AI 편집 친화적 -- MIT 라이선스 -- markdown/mermaid 변환기 보유 -- onChange 이벤트로 동기화 후킹 용이 - -### 5.8 단점 - -- localforage 기반 → 파일 시스템 전환 필요 -- Plait v0.92.1 (초기 단계, API 변경 가능) -- markdown-to-drawnix / mermaid-to-drawnix가 별도 저장소 - -### 5.9 Vync 차용 전략 - -| 차용 (직접 사용, MIT) | 차용 X | -|---------------------|--------| -| PlaitElement[] 데이터 모델 | localforage/IndexedDB | -| Drawnix React 컴포넌트 (포크) | 기존 Next.js 구조 그대로 | -| onChange/onValueChange 이벤트 | | -| markdown-to-drawnix (npm) | | -| mermaid-to-drawnix (npm) | | -| Plait 플러그인 체계 | | - ---- - -## 6. 4개 프로젝트 종합 비교 - -| 측면 | mcp_excalidraw | drawio-mcp | DeepDiagram | Drawnix | -|------|---------------|------------|-------------|---------| -| **Tool 수** | 26개 | 3개 | N/A (Agent) | N/A (UI) | -| **피드백 루프** | O (describe+screenshot) | X | X (단방향 생성) | X | -| **세밀한 제어** | Element-level | 생성만 | Agent 수준 | UI 직접 | -| **AI 편집** | MCP Tool | MCP Tool | LLM Agent | 수동 | -| **데이터 모델** | Excalidraw JSON | mxGraph XML | 다중 (6종) | PlaitElement[] | -| **의도 분류** | X | X | 3단계 Router | X | -| **실시간 동기화** | WebSocket | X | SSE | X (localStorage) | -| **라이선스** | MIT | Apache 2.0 | **AGPL-3.0** | MIT | -| **Vync 적합도** | 95% | 60% | 패턴만 | UI 기반 | - ---- - -## 7. Vync 통합 아키텍처 설계 - -### 7.1 하이브리드 아키텍처 (파일 SSoT + MCP) - -**핵심 원칙:** -- 파일 = Source of Truth (vim, VS Code, Claude Code 모두 동일 파일 편집) -- MCP = optional enhancement (없어도 동작, 있으면 구조화된 AI 조작) -- graceful degradation (MCP 서버 꺼져도 시스템 정상) - -``` -┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ -│ Claude Code │ │ vim / VS Code │ │ 웹 브라우저 │ -│ ┌────────────┐ │ │ │ │ ┌────────────┐ │ -│ │ MCP Client │ │ │ 직접 파일 편집 │ │ │ Drawnix UI │ │ -│ └─────┬──────┘ │ │ │ │ └─────┬──────┘ │ -│ 직접 편집도 가능 │ │ │ │ onChange │ -└────────┼─────────┘ └────────┬─────────┘ │ (debounce 300ms)│ - │ stdio │ └────────┼─────────┘ - ┌─────▼──────────┐ │ ┌────────▼─────────┐ - │ Vync MCP Server│ │ │ Next.js API │ - │ (독립 프로세스) │ │ │ PUT /api/file │ - │ 14개 Tool │ │ └────────┬─────────┘ - └─────┬──────────┘ │ │ - └─────────────────────┼───────────────────────┘ - ▼ - ┌─────────────────────┐ - │ .vync JSON 파일 │ - │ (Source of Truth) │ - └──────────┬──────────┘ - ▼ - ┌─────────────────────┐ - │ chokidar 감시 │ - │ + echo prevention │ - └──────────┬──────────┘ - ▼ - ┌─────────────────────┐ - │ WebSocket 방송 │ - └──────────┬──────────┘ - ▼ - ┌─────────────────────┐ - │ 브라우저 board 갱신 │ - └─────────────────────┘ -``` - -### 7.2 MCP Tool 설계 (14개, 3 Tier) - -**Tier 1 — 핵심 CRUD (Phase 2):** - -| Tool | 입력 | 출력 | -|------|------|------| -| `vync_read_file` | filePath | elements, viewport, theme, metadata | -| `vync_write_file` | filePath, elements, viewport?, theme? | success, elementCount | -| `vync_describe_scene` | filePath | summary, diagramType, elementTree, stats | -| `vync_add_element` | filePath, element, parentId? | success, elementId | -| `vync_update_element` | filePath, elementId, updates | success, previousState | -| `vync_delete_element` | filePath, elementId, deleteChildren? | success, deletedCount | - -**Tier 2 — 변환 도구 (Phase 2 후반):** - -| Tool | 입력 | 출력 | -|------|------|------| -| `vync_from_markdown` | filePath, markdown, mode | success, elementCount, rootId | -| `vync_from_mermaid` | filePath, mermaid, mode | success, elementCount | - -**Tier 3 — AI 강화 (Phase 3):** - -| Tool | 입력 | 출력 | -|------|------|------| -| `vync_generate` | filePath, instruction, diagramType? | success, elementCount, detectedType | -| `vync_edit` | filePath, instruction | success, changes[] | -| `vync_get_screenshot` | filePath, format?, width? | imageData (base64) | -| `vync_list_files` | directory?, recursive? | files[] | -| `vync_snapshot` | filePath, snapshotName | success, snapshotPath | -| `vync_restore_snapshot` | filePath, snapshotName | success | - -### 7.3 AI 편집 파이프라인 (클린룸) - -``` -자연어 입력 - │ -Intent Router (router.ts) -├─ 1단계: 명시적 접두사 (@mindmap, @flow, @canvas, @edit) -├─ 2단계: 키워드 휴리스틱 ("구조"→mindmap, "프로세스"→flow) -└─ 3단계: LLM 의도 분류 (Anthropic API + 컨텍스트) - │ - ├── MindmapAgent → LLM → Markdown → md-to-drawnix → MindElement[] - ├── FlowAgent → LLM → Mermaid → mmd-to-drawnix → DrawElement[] - ├── CanvasAgent → LLM → PlaitElement[] 직접 생성 - └── EditAgent → 현재 장면 + 명령 → JSON Patch → 수정된 PlaitElement[] - │ - ▼ -.vync 파일 저장 → chokidar → WS → UI 갱신 -``` - -### 7.4 기술 스택 - -| 영역 | 선택 | 근거 | -|------|------|------| -| 언어 | TypeScript 전체 | 단일 언어, Drawnix 호환 | -| 프론트엔드 | Drawnix 포크 | MIT, 통합 지원 | -| 웹 서버 | Next.js 14+ | Drawnix 기반 | -| 실시간 | WebSocket (ws) | 양방향, 확장 가능 | -| 파일 감시 | chokidar | Node.js 표준 | -| MCP | @modelcontextprotocol/sdk | 공식 SDK | -| 변환기 | markdown/mermaid-to-drawnix | MIT, npm | -| LLM | @anthropic-ai/sdk | Router + Agent | -| 모노레포 | pnpm + turborepo | 패키지 분리 | -| 테스트 | Vitest | Vite 호환 | - ---- - -## 8. 구현 로드맵 (Phase 1~3) - -### Phase 1: 양방향 파일 동기화 - -| # | 작업 | 의존 | -|---|------|------| -| 1.1 | 모노레포 설정 (pnpm + turborepo) | — | -| 1.2 | .vync 파일 포맷 타입 + reader/writer/validator | 1.1 | -| 1.3 | Drawnix 포크 → packages/web 배치, 실행 확인 | 1.1 | -| 1.4 | localforage → API Route (GET/PUT /api/file) | 1.2, 1.3 | -| 1.5 | chokidar + WebSocket 서버 | 1.4 | -| 1.6 | 프론트엔드 WS 클라이언트 → board 갱신 | 1.5 | -| 1.7 | onChange → debounce(300ms) → PUT /api/file | 1.4 | -| 1.8 | 에코 방지 (content hash) | 1.5, 1.7 | -| 1.9 | 원자적 쓰기 (tmp + rename) | 1.4 | -| 1.10 | 동기화 상태 UI | 1.6 | - -### Phase 2: MCP 서버 + 핵심 도구 - -| # | 작업 | 의존 | -|---|------|------| -| 2.1 | MCP 서버 설정 (stdio) | 1.1 | -| 2.2 | Tier 1: read_file, write_file | 1.2, 2.1 | -| 2.3 | describe_scene | 2.2 | -| 2.4 | add/update/delete_element | 2.2 | -| 2.5 | Tier 2: from_markdown | 2.2 | -| 2.6 | from_mermaid | 2.2 | -| 2.7 | Claude Code 등록 | 2.1 | -| 2.8 | E2E 검증 | 전체 | - -### Phase 3: AI 파이프라인 - -| # | 작업 | 의존 | -|---|------|------| -| 3.1 | Intent Router | 2.1 | -| 3.2 | MindmapAgent | 2.5, 3.1 | -| 3.3 | FlowAgent | 2.6, 3.1 | -| 3.4 | CanvasAgent | 3.1 | -| 3.5 | EditAgent | 2.3, 3.1 | -| 3.6 | vync_generate (Router+Agent) | 3.1~3.4 | -| 3.7 | vync_edit | 3.5 | -| 3.8 | list_files, snapshot, restore | 2.2 | -| 3.9 | 충돌 알림 UI | 1.6 | -| 3.10 | get_screenshot (Puppeteer) | Phase 1 | - ---- - -## 9. 결정사항 요약 - -| ID | 결정 | 근거 | -|----|------|------| -| D-003 | 하이브리드 아키텍처 (파일 SSoT + MCP) | 파일 보편성 + AI 피드백 루프 양립 | -| D-004 | WebSocket | 양방향, 멀티유저 확장 | -| D-005 | PoC 범위: Phase 1~3 | AI 파이프라인까지 검증 | -| D-006 | DeepDiagram 클린룸 재구현 | AGPL 오염 방지 | -| D-007 | TypeScript 전체 | 단일 언어 통일 | -| D-008 | 에코 방지: Content Hash | 타이밍 독립적, 신뢰성 | -| D-009 | 충돌 해결: Last Write Wins + 알림 | PoC 단순함 우선 | - ---- - -## 10. 미결 이슈 - -| ID | 이슈 | 결정 시점 | -|----|------|---------| -| O-001 | Vync 라이선스 | Phase 1 완료 후 | -| O-002 | Drawnix 포크 전략 (전체 복사 vs submodule vs npm) | Phase 1.3 전 | -| O-003 | Next.js + WebSocket 통합 방식 | Phase 1.5 전 | -| O-004 | MCP 서버의 파일 접근 방식 (직접 fs vs API 경유) | Phase 2.1 전 | -| O-005 | Plait v0.92.1 API 안정성 | Phase 1.3에서 평가 | diff --git a/docs/archive/SESSION_CONTEXT_v0.md b/docs/archive/SESSION_CONTEXT_v0.md deleted file mode 100644 index 48332db..0000000 --- a/docs/archive/SESSION_CONTEXT_v0.md +++ /dev/null @@ -1,220 +0,0 @@ -# Vync — 세션 컨텍스트 보존 문서 - -> 기획 세션(2026-03-07)에서 논의된 핵심 맥락과 사고 과정을 보존한다. -> 후속 세션에서 이 문서를 읽으면 설계 의도를 완전히 복원할 수 있어야 한다. - ---- - -## 1. 프로젝트의 근본 동기 - -**문제**: AI(Claude Code 등)와 인간이 대화하면서 계획을 수립할 때, 텍스트만으로는 복잡한 구조를 이해하기 어렵다. - -**해결**: 로컬 파일을 Single Source of Truth로 하여, 웹 UI의 시각적 편집과 외부 프로세스(AI/에디터)의 파일 수정이 양방향으로 실시간 동기화되는 도구를 만든다. - -**핵심 가치**: "같은 파일을 바라보며 이해도를 맞추는 워크플로우" - ---- - -## 2. 핵심 사용 시나리오 (우선순위순) - -1. **Claude Code와 협업 계획 수립** — AI가 .vync JSON을 생성/수정하면 웹 UI에 마인드맵/플로우차트가 실시간 반영 -2. **시각적 미세 조정** — AI가 만든 구조를 사용자가 웹 UI에서 노드 이동, 수정, 삭제 -3. **기존 시각화를 AI에게 공유** — 사용자가 웹에서 만든 것을 Claude Code가 .vync 파일을 읽어 이해 -4. **혼자 시각적 정리** — Claude 없이 순수 화이트보드로 사용 - ---- - -## 3. 설계 결정의 사고 과정 - -### 3.1 "왜 Drawnix 포크인가" - -세 가지 옵션을 분석했다: - -- **포크+수정**: 기존 UI 100% 재사용, 저장 메커니즘만 교체. 가장 빠른 시작. -- **Plait 직접 사용**: 완전한 제어권이지만 UI를 처음부터 만들어야 함. 개발 시간 2~3배. -- **Drawnix 임베드**: 결합도는 낮지만 Drawnix가 외부 데이터 주입 API를 제공하는지 불확실. - -MVP 목표에서 속도와 완성도의 균형점은 **포크+수정**이었다. 장기적으로 Drawnix가 요구에 맞지 않으면 Plait 직접 사용으로 전환할 수 있다 (Fallback 경로). - -### 3.2 "왜 JSON 직접 편집인가 (Markdown/Mermaid 변환 제외)" - -세 가지 AI 편집 경로를 분석했다: - -- **소스 파일 기반** (.md → 자동 변환 → .vync): AI에게 쉽지만, 웹 UI 편집을 소스에 역변환하는 것이 기술적으로 매우 어려움. 양방향 호환 불가. -- **JSON 직접 편집**: 단일 포맷으로 양방향 완전 호환. 다만 PlaitElement[] 구조가 복잡. -- **하이브리드**: 두 모드를 관리해야 하는 복잡성. - -**결정적 요인**: "양방향 완전 호환"이 프로젝트의 핵심 가치(같은 파일을 바라보며 동기화)와 가장 부합. PlaitElement[] 복잡성은 CLAUDE.md + JSON Schema + 예시로 완화. - -### 3.3 "왜 CLI 중심인가" - -사용 시나리오에서 사용자는 **이미 터미널에서 Claude Code와 작업 중**이다. 따라서: -- 파일 관리를 위해 웹 UI에 별도 기능을 만드는 것은 과도 -- `vync open plan.vync` 한 줄이면 충분 -- 웹 UI는 순수 캔버스 에디터로서 복잡도를 최소화 - -### 3.4 "왜 조용히 자동 반영인가" - -세 가지 알림 방식을 분석했다: -- **토스트+자동 반영**: 정보성이지만, AI가 빈번히 수정하면 토스트가 스팸처럼 됨 -- **조용히 자동**: Google Docs처럼 자연스러운 경험. AI 수정이 "그냥 나타나는" 느낌 -- **확인 팝업**: 작업 흐름을 방해. AI 협업에서는 최악의 UX - -**조용히 자동**을 선택했지만, 사용자가 편집 중일 때 외부 변경이 덮어쓰는 문제는 인지하고 있다. 이건 구현 시 "편집 중인 요소와 외부 변경 요소가 겹치는지"를 판단하여 후속 개선 가능. - -### 3.5 "왜 래핑된 JSON인가" - -- **네이키드 PlaitElement[]**: Drawnix 내부 포맷과 동일하여 호환성 높지만, 버전/뷰포트 메타데이터를 넣을 곳이 없음 -- **래핑 `{ version, viewport, elements }`**: 약간의 오버헤드로 확장성 확보. AI는 `elements` 필드만 편집하면 됨 - ---- - -## 4. 아키텍처 다이어그램 - -``` -┌──────────────┐ ┌──────────────────┐ ┌──────────────┐ -│ Claude Code │ │ .vync JSON 파일 │ │ 웹 브라우저 │ -│ (JSON 편집) │ ── Write/Edit ──→ │ (Source of Truth) │ ── chokidar ──→ │ Vync UI │ -│ │ │ │ │ (캔버스 편집) │ -│ │ ←── Read ──────── │ │ ←── auto-save ── │ │ -└──────────────┘ └──────────────────┘ └──────────────┘ - ↕ WebSocket - ┌──────────────────┐ - │ Next.js Custom │ - │ Server (:3000) │ - │ HTTP + WS + │ - │ chokidar │ - └──────────────────┘ -``` - -**데이터 흐름**: - -``` -[외부 편집 → 웹 UI] - 외부에서 .vync 파일 수정 - → chokidar 감지 - → content hash 비교 (에코 방지) - → 실제 변경이면 WebSocket으로 전송 - → 프론트엔드가 PlaitElement[] 교체 - → 캔버스 자동 업데이트 (조용히) - -[웹 UI → 파일] - 캔버스에서 노드 편집 - → Plait onChange 이벤트 - → 디바운싱 (300~500ms) - → WebSocket 또는 API로 서버에 전송 - → 서버가 원자적 쓰기 (tmp + rename) - → content hash 업데이트 (에코 방지) -``` - ---- - -## 5. 파일 구조 (예상) - -``` -Vync/ # Drawnix 포크 -├── docs/ -│ ├── PLAN.md # 원본 기획서 -│ ├── DECISIONS.md # 설계 결정서 (이 세션의 결과) -│ └── SESSION_CONTEXT.md # 세션 컨텍스트 보존 (이 문서) -├── examples/ -│ ├── mindmap.vync # 마인드맵 예시 파일 -│ └── flowchart.vync # 플로우차트 예시 파일 -├── bin/ -│ └── vync.js # CLI 진입점 (vync init, vync open) -├── src/ -│ ├── server/ -│ │ ├── custom-server.ts # Next.js Custom Server (HTTP + WS) -│ │ ├── file-watcher.ts # chokidar 파일 감시 -│ │ ├── sync-service.ts # 동기화 로직 (에코 방지, 원자적 쓰기) -│ │ └── ws-handler.ts # WebSocket 메시지 핸들러 -│ └── ... (기존 Drawnix 소스) -├── .vync.schema.json # JSON Schema -├── CLAUDE.md # AI 편집 가이드 -├── pnpm-workspace.yaml -└── package.json -``` - ---- - -## 6. 핵심 기술 과제 상세 - -### 6.1 에코 방지 메커니즘 - -``` -웹 편집 → 파일 저장 → chokidar 감지 → 다시 웹으로 → 무한 루프 -``` - -**선택한 방법**: content hash 비교 -- 서버가 파일을 쓸 때마다 해당 내용의 SHA-256 해시를 메모리에 저장 -- chokidar가 변경을 감지하면, 새 파일 내용의 해시를 계산 -- 저장된 해시와 동일하면 → 자체 쓰기이므로 무시 -- 다르면 → 외부 변경이므로 WebSocket으로 전파 - -### 6.2 원자적 파일 쓰기 - -```javascript -// 반쯤 쓰인 파일을 읽는 것을 방지 -writeFileSync(tmpPath, content); // 임시 파일에 쓰기 -renameSync(tmpPath, targetPath); // 원자적 교체 -``` - -### 6.3 디바운싱 - -- **웹 → 파일**: onChange 이벤트를 300~500ms 디바운싱 -- **파일 → 웹**: chokidar 이벤트를 300ms 디바운싱 (에디터가 여러 번 저장할 수 있으므로) - -### 6.4 JSON 유효성 검증 - -- 파일 읽기 시 JSON.parse 실패하면 이전 유효한 상태를 유지 -- 에러 로그를 서버 콘솔에 출력 (웹 UI에는 표시하지 않음) - ---- - -## 7. PlaitElement[] 구조 (Phase 1에서 상세화 필요) - -Phase 1에서 Drawnix를 실행하고 실제 데이터를 분석해야 한다. 현재까지 알려진 정보: - -- **마인드맵**: `@plait/mind` — 계층적 노드 구조, 자동 레이아웃 -- **플로우차트/도형**: `@plait/draw` — 노드 + 연결선, 수동 좌표 배치 -- **변환 도구**: `markdown-to-drawnix`, `mermaid-to-drawnix` — 이들의 출력 구조가 PlaitElement[]의 실제 형태를 이해하는 열쇠 - -**Phase 1 핵심 작업**: -1. Drawnix에서 마인드맵을 만들고 localStorage/IndexedDB에서 JSON 추출 -2. 플로우차트도 동일하게 JSON 추출 -3. 두 JSON의 구조를 비교 분석 -4. markdown-to-drawnix CLI로 변환 결과 확인 -5. AI가 편집하기 쉬운 필드 vs 어려운 필드 분류 -6. 결과를 .vync.schema.json과 CLAUDE.md에 반영 - ---- - -## 8. 성공 기준 (MVP) - -- [ ] `vync init plan.vync` 로 빈 캔버스 파일 생성됨 -- [ ] `vync open plan.vync` 로 서버 시작 + 브라우저에서 캔버스 렌더링됨 -- [ ] 웹 UI에서 노드 추가/이동/삭제 → .vync 파일이 자동 저장됨 -- [ ] 외부에서(vim, Claude Code 등) .vync 파일 수정 → 웹 UI가 자동 갱신됨 (조용히) -- [ ] Claude Code가 CLAUDE.md를 읽고 .vync JSON을 올바르게 편집할 수 있음 -- [ ] 전체 루프(외부 편집 → 웹 반영, 웹 편집 → 파일 저장)가 3초 이내 -- [ ] JSON 파싱 실패 시 이전 상태 유지 (크래시 없음) -- [ ] 에코 루프 없이 안정적으로 동작 - ---- - -## 9. 미결 질문 (구현 시 결정) - -1. **WebSocket 메시지 포맷**: 전체 파일 내용 전송 vs diff/patch 전송? - → MVP에서는 전체 파일 전송이 단순. 파일 크기가 문제될 때 최적화. - -2. **Drawnix의 저장 메커니즘 구조**: localStorage 기반 코드가 어디에 있고, 어떻게 교체 가능한지? - → Phase 1에서 코드 분석 필요. - -3. **Plait board 업데이트 API**: 외부에서 데이터를 주입하여 캔버스를 업데이트하는 API가 존재하는지? - → Phase 1에서 Plait 문서/코드 분석 필요. - -4. **Custom Server에서 Next.js 개발 모드와 프로덕션 모드 처리**: HMR과 WebSocket이 충돌하지 않는지? - → Phase 2에서 구현 시 확인. - -5. **ID 생성 규칙**: PlaitElement의 id 필드는 어떤 포맷? UUID? nanoid? 숫자? - → Phase 1에서 Drawnix 코드 분석으로 확인. diff --git a/docs/plans/2026-03-10-server-lifecycle-fix.md b/docs/plans/2026-03-10-server-lifecycle-fix.md deleted file mode 100644 index 4bd4bf7..0000000 --- a/docs/plans/2026-03-10-server-lifecycle-fix.md +++ /dev/null @@ -1,420 +0,0 @@ -# Server Process Lifecycle Fix Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Electron EADDRINUSE recovery + CLI/Server identity 검증을 추가하여 포트 충돌 시 자동 복구되도록 한다. - -**Architecture:** 서버 health endpoint에 `pid`를 추가하고, Electron과 CLI 양쪽에서 포트 충돌 시 기존 서버를 발견·재사용하는 recovery 경로를 추가한다. 기존 happy path는 변경하지 않는다. - -**Tech Stack:** TypeScript, Node.js, Express, Electron, vitest - ---- - -## 배경 - -3개 에이전트 리뷰 결과 도출된 근본 원인 1개: -- **Electron이 EADDRINUSE 시 기존 서버에 연결하는 recovery 경로가 없다** -- 부수적으로 `runElectron`이 `registerFile`을 호출하지 않는 비대칭 존재 - -수정 우선순위: Task 1(health pid) → Task 2(Electron recovery) → Task 3(CLI 포트 감지) → Task 4(runElectron registerFile) - ---- - -### Task 1: Health Endpoint에 `pid` 추가 - -**Files:** -- Modify: `tools/server/server.ts:57-58` -- Test: `tools/server/__tests__/server.test.ts` (기존 health 테스트가 있으면 수정, 없으면 확인) - -**Step 1: health endpoint에 process.pid 추가** - -```typescript -// tools/server/server.ts:57-58 -// Before: -app.get('/api/health', (_req, res) => { - res.json({ version: 2, mode: 'hub', fileCount: registry.listFiles().length }); -}); - -// After: -app.get('/api/health', (_req, res) => { - res.json({ version: 2, mode: 'hub', pid: process.pid, fileCount: registry.listFiles().length }); -}); -``` - -**Step 2: 기존 테스트 확인 및 실행** - -Run: `npx vitest run --reporter=verbose 2>&1 | head -80` -Expected: 기존 테스트 모두 PASS (health 응답 shape이 바뀌므로 관련 assertion 업데이트 필요할 수 있음) - -**Step 3: Commit** - -```bash -git add tools/server/server.ts -git commit -m "feat(server): add pid to health endpoint for identity verification" -``` - ---- - -### Task 2: Electron EADDRINUSE Recovery - -**Files:** -- Modify: `tools/electron/main.ts:82-101` (openFile의 server start catch 블록) - -**Step 1: Electron에 기존 서버 연결 recovery 로직 추가** - -`tools/electron/main.ts`의 `openFile` 함수에서 `startServer` 실패 시 EADDRINUSE를 감지하고, 기존 서버에 연결을 시도한다. - -```typescript -// tools/electron/main.ts — openFile 함수의 else 블록 교체 -// Before (line 82-101): - } else { - try { - const { startServer } = await import('../server/server.js'); - const isDev = !app.isPackaged; - const staticDir = isDev - ? undefined - : path.join(process.resourcesPath, 'dist', 'apps', 'web'); - serverHandle = await startServer({ - initialFile: resolved, - port: 3100, - mode: isDev ? 'development' : 'production', - staticDir, - }); - } catch (err: any) { - dialog.showErrorBox('Vync Error', err.message); - app.quit(); - return; - } - } - -// After: - } else { - try { - const { startServer } = await import('../server/server.js'); - const isDev = !app.isPackaged; - const staticDir = isDev - ? undefined - : path.join(process.resourcesPath, 'dist', 'apps', 'web'); - serverHandle = await startServer({ - initialFile: resolved, - port: 3100, - mode: isDev ? 'development' : 'production', - staticDir, - }); - } catch (err: any) { - // EADDRINUSE: try connecting to existing server - if (err.message.includes('already in use')) { - const existingUrl = 'http://localhost:3100'; - try { - const res = await fetch(`${existingUrl}/api/health`, { - signal: AbortSignal.timeout(2000), - }); - if (res.ok) { - const body = await res.json(); - if (body.version === 2) { - // Reuse existing server (no shutdown responsibility) - serverHandle = { shutdown: async () => {}, url: existingUrl }; - console.log(`[vync] Reusing existing server (PID ${body.pid})`); - } else { - dialog.showErrorBox('Vync Error', 'Incompatible server on port 3100'); - app.quit(); - return; - } - } else { - dialog.showErrorBox('Vync Error', `Port 3100 in use by non-Vync process`); - app.quit(); - return; - } - } catch { - dialog.showErrorBox('Vync Error', 'Port 3100 in use but server not responding'); - app.quit(); - return; - } - } else { - dialog.showErrorBox('Vync Error', err.message); - app.quit(); - return; - } - } - } -``` - -핵심 변경: -- `serverHandle.shutdown`이 no-op이므로 Electron 창 닫힘 시 기존 서버를 죽이지 않음 -- `body.version === 2`로 Vync 서버 identity 확인 -- 기존 서버 연결 후 `registerFile`은 아래 코드 흐름(line 67-81)에서 처리됨... 아닌데, 현재 코드에서는 `!serverHandle`일 때만 서버를 시작하고, `serverHandle`이 있으면 register를 한다. 하지만 recovery 후에는 `serverHandle`이 설정되므로 다음 `openFile` 호출부터는 register 경로를 탄다. - -문제: recovery 직후의 **첫 번째 파일**은 register되지 않는다. 기존 서버의 `startServer(initialFile)` 경로를 타지 않기 때문이다. 따라서 recovery 후 명시적 register가 필요하다. - -수정된 recovery 블록 끝에 register 추가: - -```typescript - serverHandle = { shutdown: async () => {}, url: existingUrl }; - console.log(`[vync] Reusing existing server (PID ${body.pid})`); - // Register the file with existing server - await fetch(`${existingUrl}/api/files`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filePath: resolved }), - }); -``` - -**Step 2: 수동 검증 (Electron은 vitest로 테스트 어려움)** - -검증 방법: 서버를 foreground로 먼저 시작한 뒤, Electron을 실행하여 recovery 되는지 확인. - -```bash -# Terminal 1: 서버 먼저 시작 -node bin/vync.js open some-file --foreground - -# Terminal 2: Electron이 recovery하는지 확인 -npx electron dist/electron/main.js /path/to/other.vync -# Expected: "[vync] Reusing existing server" 로그 + 파일 열림 -``` - -**Step 3: Commit** - -```bash -git add tools/electron/main.ts -git commit -m "fix(electron): recover from EADDRINUSE by reusing existing server" -``` - ---- - -### Task 3: CLI `isServerRunning` 포트 기반 감지 추가 - -**Files:** -- Modify: `tools/cli/open.ts:67-96` (isServerRunning 함수) -- Test: `tools/cli/__tests__/open.test.ts` - -**Step 1: isServerRunning에 포트 프로브 fallback 추가** - -현재 `isServerRunning`은 PID 파일이 없으면 즉시 `running: false`를 반환한다. PID 파일이 없어도 포트에서 Vync 서버가 발견되면 PID 파일을 복구하고 `running: true`를 반환하도록 한다. - -```typescript -// tools/cli/open.ts — isServerRunning 함수 -// Before: -async function isServerRunning(): Promise<{ running: boolean; info: ServerInfo | null }> { - const info = await readServerInfo(); - if (!info) return { running: false, info: null }; - // ... PID check, health check ... -} - -// After: -async function isServerRunning(): Promise<{ running: boolean; info: ServerInfo | null }> { - const info = await readServerInfo(); - - if (info) { - // Check if process is alive - try { - process.kill(info.pid, 0); - } catch { - await fs.unlink(PID_FILE).catch(() => {}); - // Fall through to port probe below - return probePort(); - } - - // Health check - try { - const res = await fetch(`http://localhost:${info.port}/api/health`, { - signal: AbortSignal.timeout(1000), - }); - if (res.ok) { - const body = await res.json(); - if (body.version === 2) return { running: true, info }; - // Old server -> stop it - await vyncStop(); - return { running: false, info: null }; - } - } catch {} - - // PID alive but HTTP dead -> stale - await fs.unlink(PID_FILE).catch(() => {}); - return { running: false, info: null }; - } - - // No PID file — probe port as fallback - return probePort(); -} - -async function probePort(): Promise<{ running: boolean; info: ServerInfo | null }> { - try { - const res = await fetch(`http://localhost:${PORT}/api/health`, { - signal: AbortSignal.timeout(1000), - }); - if (!res.ok) return { running: false, info: null }; - const body = await res.json(); - if (body.version !== 2) return { running: false, info: null }; - - // Recover PID file from health response - const recoveredInfo: ServerInfo = { - version: 2, - pid: body.pid, - mode: 'daemon', // best guess - port: PORT, - }; - await writeServerInfo(recoveredInfo); - console.log(`[vync] Discovered existing server (PID ${body.pid}), recovered PID file.`); - return { running: true, info: recoveredInfo }; - } catch { - return { running: false, info: null }; - } -} -``` - -**Step 2: 테스트 작성** - -```typescript -// tools/cli/__tests__/open.test.ts — 추가 -describe('probePort', () => { - // probePort는 private이므로 isServerRunning을 통해 간접 테스트 - // 실제 서버 없이 테스트하기 어려우므로 통합 테스트로 검증 -}); -``` - -주의: `probePort`는 실제 네트워크 호출이므로 유닛 테스트보다 수동 E2E로 검증한다. - -**Step 3: 기존 테스트 실행** - -Run: `npx vitest run tools/cli/__tests__/open.test.ts --reporter=verbose` -Expected: 기존 10개 테스트 모두 PASS - -**Step 4: Commit** - -```bash -git add tools/cli/open.ts -git commit -m "fix(cli): add port probe fallback when PID file is missing" -``` - ---- - -### Task 4: `runElectron`에 `registerFile` 추가 (비대칭 해소) - -**Files:** -- Modify: `tools/cli/open.ts:193-215` (runElectron의 폴링 성공 후) - -**Step 1: runElectron 폴링 성공 시 registerFile + openBrowser 추가** - -`runDaemon`(line 282-284)과 동일하게 health check 성공 후 `registerFile`과 `openBrowserWithFile`을 호출한다. - -```typescript -// tools/cli/open.ts — runElectron 폴링 루프 내 -// Before (line 203-208): - try { - const res = await fetch(`${url}/api/health`); - if (res.ok) { - console.log(`[vync] Vync app running (PID ${childPid})`); - console.log(`[vync] Log: ${LOG_FILE}`); - return; - } - } catch { - -// After: - try { - const res = await fetch(`${url}/api/health`); - if (res.ok) { - const body = await res.json(); - // Verify this is our server, not a ghost - if (body.pid !== childPid) { - // Ghost server detected — reuse it instead - console.log(`[vync] Existing server found (PID ${body.pid}), reusing.`); - await writeServerInfo({ version: 2, pid: body.pid, mode: 'daemon', port: PORT }); - } else { - console.log(`[vync] Vync app running (PID ${childPid})`); - } - // Register file (same as runDaemon) - await registerFile(PORT, resolved); - console.log(`[vync] Log: ${LOG_FILE}`); - return; - } - } catch { -``` - -핵심 변경: -- `body.pid !== childPid` 비교로 고스트 서버 오인 방지 (Task 1의 health pid에 의존) -- 고스트 서버일 경우 PID 파일을 실제 서버 PID로 교정 -- `registerFile` 호출 추가 (runDaemon과 동일) -- `openBrowserWithFile`은 Electron 모드에서는 불필요 (Electron이 자체 윈도우를 관리) - -**Step 2: 기존 테스트 실행** - -Run: `npx vitest run --reporter=verbose` -Expected: 전체 PASS - -**Step 3: Commit** - -```bash -git add tools/cli/open.ts -git commit -m "fix(cli): add registerFile to runElectron, verify server identity" -``` - ---- - -### Task 5: 통합 E2E 검증 - -**Files:** 없음 (수동 검증) - -**Step 1: 시나리오 A — 정상 시작 (regression 없음)** - -```bash -node bin/vync.js stop 2>/dev/null # 기존 서버 정리 -node bin/vync.js open project-status -# Expected: 서버 시작 + 브라우저 열림 -node bin/vync.js stop -``` - -**Step 2: 시나리오 B — 고스트 서버 + vync open (핵심 시나리오)** - -```bash -# 서버를 foreground로 시작 (PID 파일 있음) -node bin/vync.js open some-file --foreground & -FOREGROUND_PID=$! - -# PID 파일 삭제 (고스트 상태 시뮬레이션) -rm ~/.vync/server.pid - -# vync open이 포트 프로브로 기존 서버를 발견하는지 확인 -node bin/vync.js open project-status -# Expected: "[vync] Discovered existing server" + 파일 열림 - -kill $FOREGROUND_PID -``` - -**Step 3: 시나리오 C — Electron EADDRINUSE recovery** - -```bash -# daemon 서버 먼저 시작 -node bin/vync.js open some-file --foreground & - -# Electron 직접 실행 (EADDRINUSE 발생해야 함) -npx electron dist/electron/main.js .vync/project-status.vync -# Expected: "[vync] Reusing existing server" + 윈도우 열림 - -kill %1 -``` - -**Step 4: 전체 테스트 실행** - -Run: `npx vitest run --reporter=verbose` -Expected: 전체 PASS - -**Step 5: Commit (모든 검증 통과 시)** - -```bash -git add -A -git commit -m "test: verify server lifecycle recovery scenarios" -``` - ---- - -## 변경 요약 - -| Task | 파일 | 변경량 | 해결하는 문제 | -|------|------|--------|--------------| -| 1 | server.ts | ~1 LOC | health에 pid 추가 (기반) | -| 2 | electron/main.ts | ~25 LOC | Electron EADDRINUSE recovery | -| 3 | cli/open.ts | ~25 LOC | PID 파일 없을 때 포트 프로브 | -| 4 | cli/open.ts | ~10 LOC | runElectron registerFile + identity 검증 | -| 5 | (수동) | 0 LOC | 통합 E2E 검증 | - -**총 변경: ~60 LOC, 3개 파일** diff --git a/docs/plans/2026-03-11-diff-pipeline-poc-results.md b/docs/plans/2026-03-11-diff-pipeline-poc-results.md deleted file mode 100644 index 7efe891..0000000 --- a/docs/plans/2026-03-11-diff-pipeline-poc-results.md +++ /dev/null @@ -1,137 +0,0 @@ -# Diff Pipeline PoC 결과 보고 - -**Date**: 2026-03-11 -**선행 문서**: `2026-03-11-diff-pipeline-redesign.md` (설계), `2026-03-11-diff-pipeline-poc.md` (실행 가이드) - ---- - -## 1. 목적 - -Sub-agent(vync-translator)를 "기계적 JSON 변환기"에서 "시각화 전문가(맥락 인지형)"로 업그레이드하기 위한 3가지 핵심 가설을 검증한다. - ---- - -## 2. 가설 및 결과 - -| ID | 가설 | 검증 방법 | 결과 | -|----|------|-----------|------| -| H1 | Sub-agent가 프로그래밍적 diff를 받아서 의미적 번역이 가능하다 | Trial 1 | **PASS** | -| H2 | 대화 맥락 힌트를 함께 전달하면 번역 품질이 향상된다 | Trial 1 vs Trial 2 비교 | **PASS** | -| H3 | Sub-agent가 맥락만으로 시각화 전략을 자율 판단할 수 있다 | Trial 3 | **PASS** | - ---- - -## 3. Trial 상세 - -### Trial 1: Diff 번역 — 맥락 없이 (H1) - -**입력**: 합성 diff만 전달 (Added 1건, Modified 1건), 대화 맥락 없음 - -**Sub-agent 반환 (원문 요약)**: -> 두 변경사항은 하나의 사고 흐름을 보여줍니다. -> "develop → main 머지 대기"가 "완료 ✅"로 바뀐 것은 **유저가 막혀 있던 게이트를 통과했다는 인식의 전환**입니다. -> "Phase 10: Diff 파이프라인 개선"이 같은 "진행 중" 버킷에 추가된 것은, **완료의 여운 속에서 다음 관심이 이미 확정된 상태**로 진입했다는 의미입니다. - -**판정 기준 충족 여부**: -- [x] 변경사항 정확 인식 (2건: 추가 1, 수정 1) -- [x] 의미적 번역 수행 ("게이트 통과", "다음 관심 확정" — 단순 나열 아님) -- [x] 메인 세션 활용 가능 품질 (자연스러운 prose) - -**판정: PASS** - -**관찰**: 맥락 없이도 "사고 흐름의 변화"를 해석할 수 있으나, 프로젝트 구체 맥락과의 연결은 부족 (추상적 수준에 머무름) - ---- - -### Trial 2: 맥락 + Diff 번역 (H2) - -**입력**: 동일 diff + 대화 맥락 (diff 파이프라인 개선 논의, Sub-agent 역할 업그레이드 방향) - -**Sub-agent 반환 (원문 요약)**: -> 머지 완료로 백로그 하나가 닫히는 순간, 유저는 이미 다음 작업을 명명했습니다. -> "향후 로드맵"의 추상적인 항목(P2: AI Agent 파이프라인)에 머물지 않고, **현재 논의 중인 Sub-agent 개선을 Phase 10이라는 구체적인 단위로 격상**시킨 것입니다. -> Diff 파이프라인 개선은 **로드맵 항목이 아니라 지금 당장의 작업으로 인식**되고 있습니다. - -**Trial 1 vs Trial 2 비교**: - -| 관점 | Trial 1 (맥락 없음) | Trial 2 (맥락 있음) | -|------|---------------------|---------------------| -| 해석 수준 | 추상적 ("게이트 통과", "다음 관심 확정") | 구체적 ("Phase 10으로 격상", "지금 당장의 작업") | -| 대화 연결 | 없음 | "현재 논의 중인 Sub-agent 개선"에 직접 연결 | -| 인사이트 | 일반적 사고 변화 패턴 | 프로젝트 의사결정 맥락에 기반한 해석 | - -**판정 기준 충족 여부**: -- [x] 대화 맥락과 연결된 번역 ("현재 논의 중인 Sub-agent 개선을 Phase 10으로 격상") -- [x] Trial 1보다 관련성 높은 인사이트 포함 (로드맵 vs 당장의 작업 구분) - -**판정: PASS** - -**핵심 발견**: 맥락이 번역의 "관련성"을 결정적으로 향상시킴. "Phase 10으로 격상"이라는 표현은 맥락 없이는 불가능한 번역. - ---- - -### Trial 3: 맥락 기반 자율 시각화 (H3) - -**입력**: 대화 맥락만 전달 (현재 문제 3건, 개선 방향 3건, 3단계 파이프라인), 구조 트리 미제공 - -**Sub-agent 실행 결과**: -- 시각화 유형: **mindmap** (자율 선택 — 계층적 분류에 적합) -- 루트: "Diff 파이프라인 개선" -- 1단계 가지 3개: 현재 문제, 3단계 파이프라인, 개선 방향 -- 각 가지 하위 리프 3개씩 -- 총 노드 수: **16개** -- 최대 깊이: **3~4단계** (Stage 하위에 설명 노드 1개씩) -- validate.js: **OK** - -**판정 기준 충족 여부**: -- [x] 적절한 시각화 유형 선택 (mindmap — 계층 구조에 적합) -- [x] 핵심 포인트 포착 (3개 카테고리 모두: 현재 문제, 3단계 파이프라인, 개선 방향) -- [x] 과도하지 않은 세분화 (16노드, 주로 3단계) -- [x] 유효한 .vync JSON 생성 (validate.js 통과) - -**판정: PASS** - -**경계선 관찰**: FAIL 기준 "4단계 이상"에 대해 Stage 하위 설명 노드가 4단계에 해당. 다만 전체가 아닌 일부(3개)이며 노드 수(16 < 20)는 기준 내이므로 PASS 판정 유지. - -**핵심 발견**: Sub-agent가 "구조를 제공해주세요" 같은 거부 없이, 맥락을 분석하여 형식·구조를 자율적으로 판단하고 즉시 실행함. - ---- - -## 4. 설계 결정 확정 - -PoC 결과에 따라 다음 설계 결정이 확정됨: - -### D-015: Diff Pipeline Hybrid Architecture — 확정 -- 프로그래밍적 diff(코드) + Sub-agent 의미 번역(LLM) + 메인 세션 맥락 해석 -- **근거**: H1(diff 의미 번역 가능) + H2(맥락이 품질 향상) → 하이브리드 파이프라인 유효성 검증됨 -- 재검토 조건 "PoC 불충분" 해당 없음 - -### D-016: Sub-agent 역할 확장 (시각화 전문가) — 확정 -- 맥락 인지형 시각화 전문가. 시각화 전략 판단 + 실행 -- **근거**: H3(자율 시각화 판단 가능) → 기계적 변환기에서 전문가로 역할 업그레이드 실현 가능 -- 재검토 조건 "자율 판단 불충분" 해당 없음 - ---- - -## 5. 추가 발견 - -### 현재 Sub-agent 프롬프트 수정 불필요 -PoC는 `agents/vync-translator.md`를 **수정하지 않은 상태**로 실행. 현재 프롬프트가 새 입력 형식(맥락+diff)을 이미 잘 처리함. 다만 설계 문서(§4-2)의 정체성 변경·시각화 판단 가이드·절차 변경은 **품질 안정화를 위해 여전히 권장**. - -### 합성 diff 사용에 대한 유의점 -실제 파일의 .lastread와 .vync가 동일하여 합성 diff를 사용. 프로그래밍적 diff 엔진 구현(Phase A) 후 실제 diff 출력 포맷으로 재검증하면 더 완전. - ---- - -## 6. 다음 단계 - -설계 문서 §6 "구현 순서"에 따라: - -| Phase | 내용 | 상태 | -|-------|------|------| -| **A** | 프로그래밍적 diff 엔진 (`tools/cli/diff.ts`) | 대기 | -| **B** | PoC 실행 | **완료** | -| **C** | Sub-agent 프롬프트 재설계 (`agents/vync-translator.md`, `commands/vync.md`) | 대기 | -| **D** | 통합 테스트 (E2E: create → 브라우저 수정 → diff → read → update) | 대기 | - -**즉시 착수 가능**: Phase A (diff 엔진) — 외부 의존성 없음, 단위 테스트로 독립 검증 가능 diff --git a/docs/plans/2026-03-11-diff-pipeline-poc.md b/docs/plans/2026-03-11-diff-pipeline-poc.md deleted file mode 100644 index 32f70c5..0000000 --- a/docs/plans/2026-03-11-diff-pipeline-poc.md +++ /dev/null @@ -1,259 +0,0 @@ -# Diff Pipeline PoC — 실행 가이드 - -**Date**: 2026-03-11 -**선행 문서**: `docs/plans/2026-03-11-diff-pipeline-redesign.md` -**Status**: 완료 (3/3 PASS) - ---- - -## 배경 - -Vync의 Sub-agent(vync-translator)를 "기계적 JSON 변환기"에서 "시각화 전문가(맥락 인지형)"으로 업그레이드하려 한다. 이를 위해 3가지 가설을 검증한다. - -**핵심 변경**: Sub-agent에게 (1) 대화 맥락, (2) 프로그래밍적 diff를 입력으로 전달하여, Sub-agent가 시각화 전략을 자율적으로 판단하고 실행하게 한다. - ---- - -## 가설 - -| ID | 가설 | 검증 방법 | -|----|------|-----------| -| H1 | Sub-agent가 프로그래밍적 diff를 받아서 **의미적 번역**이 가능하다 | Trial 1 | -| H2 | **대화 맥락 힌트**를 함께 전달하면 번역 품질이 향상된다 | Trial 1 vs Trial 2 비교 | -| H3 | Sub-agent가 **맥락만으로 시각화 전략을 자율 판단**할 수 있다 | Trial 3 | - ---- - -## 사전 준비 - -### 테스트 데이터 생성 - -현재 `.vync/project-status.vync`와 `.lastread`는 콘텐츠가 동일하므로, **합성 diff 시나리오**를 사용한다. - -브라우저에서 유저가 다음과 같이 수정했다고 가정: - -``` -=== Vync Diff: project-status.vync === - -현재 구조: - Vync 프로젝트 현황 - ├── MVP 완료 ✅ (Phase 1~5) - ├── Post-MVP 완료 ✅ (Phase 6~9) - ├── 진행 중 🔧 - │ ├── macOS 코드 서명 - │ ├── develop → main 머지 대기 - │ └── Phase 10: Diff 파이프라인 개선 ← NEW - ├── 아키텍처 (Server, Web, Electron, CLI, Plugin) - └── 향후 로드맵 (P1~P5) - -변경사항: - Added: "Phase 10: Diff 파이프라인 개선" (under "진행 중 🔧") - Modified: "develop → main 머지 대기" → "develop → main 머지 완료 ✅" - Removed: (없음) -``` - -이 diff 텍스트를 각 Trial에서 사용한다. - ---- - -## Trial 실행 - -### Trial 1: Diff 번역 — 맥락 없이 (H1 검증) - -Sub-agent에게 diff만 전달하고, 의미적 번역을 요청한다. - -``` -Agent({ - description: "PoC Trial 1: diff translation", - subagent_type: "vync-translator", - prompt: `## 작업: Read -파일: /Users/presence/projects/Vync/.vync/project-status.vync - -## 유저 피드백 (diff) - -현재 구조: - Vync 프로젝트 현황 - ├── MVP 완료 ✅ (Phase 1~5) - ├── Post-MVP 완료 ✅ (Phase 6~9) - ├── 진행 중 🔧 - │ ├── macOS 코드 서명 - │ ├── develop → main 머지 완료 ✅ - │ └── Phase 10: Diff 파이프라인 개선 - ├── 아키텍처 (Server, Web, Electron, CLI, Plugin) - └── 향후 로드맵 (P1~P5) - -변경사항: - Added: "Phase 10: Diff 파이프라인 개선" (under "진행 중 🔧") - Modified: "develop → main 머지 대기" → "develop → main 머지 완료 ✅" - -## 지시 -위 변경사항을 의미적으로 번역해줘. 단순 나열이 아닌, "유저의 생각이 어떻게 변화했는지"를 요약해줘.` -}) -``` - -**판정 기준:** -- [ ] PASS: 변경사항을 정확히 인식 (2건: 추가 1, 수정 1) -- [ ] PASS: 단순 나열이 아닌 의미적 번역 ("Phase 10이 추가됨"이 아닌 "새 작업이 시작됨" 수준) -- [ ] PASS: 메인 세션이 활용하기 충분한 품질 (대화에 자연스럽게 통합 가능) -- [ ] FAIL: 변경사항 누락 또는 오독 -- [ ] FAIL: 단순 나열만 함 ("Added: X, Modified: Y"를 그대로 반복) - ---- - -### Trial 2: 맥락 + Diff 번역 (H2 검증) - -같은 diff에 대화 맥락을 추가하여 번역 품질 변화를 확인한다. - -``` -Agent({ - description: "PoC Trial 2: context + diff translation", - subagent_type: "vync-translator", - prompt: `## 작업: Read -파일: /Users/presence/projects/Vync/.vync/project-status.vync - -## 대화 맥락 -현재 Vync 프로젝트의 diff 파이프라인을 개선하는 논의가 진행 중이다. -핵심 개선 방향은 Sub-agent를 "기계적 JSON 변환기"에서 "시각화 전문가"로 업그레이드하는 것이다. -구체적으로: (1) 프로그래밍적 diff 엔진 추가, (2) Sub-agent 입력에 맥락+diff 추가, -(3) Sub-agent가 시각화 전략을 자율적으로 판단. -또한 develop → main 머지가 진행되었으며, 이 다이어그램은 프로젝트 현황을 추적하는 용도이다. - -## 유저 피드백 (diff) - -현재 구조: - Vync 프로젝트 현황 - ├── MVP 완료 ✅ (Phase 1~5) - ├── Post-MVP 완료 ✅ (Phase 6~9) - ├── 진행 중 🔧 - │ ├── macOS 코드 서명 - │ ├── develop → main 머지 완료 ✅ - │ └── Phase 10: Diff 파이프라인 개선 - ├── 아키텍처 (Server, Web, Electron, CLI, Plugin) - └── 향후 로드맵 (P1~P5) - -변경사항: - Added: "Phase 10: Diff 파이프라인 개선" (under "진행 중 🔧") - Modified: "develop → main 머지 대기" → "develop → main 머지 완료 ✅" - -## 지시 -위 변경사항을 대화 맥락에 비춰 의미적으로 번역해줘. "유저의 생각이 어떻게 변화했는지"를 요약해줘.` -}) -``` - -**판정 기준 (Trial 1 대비):** -- [ ] PASS: 대화 맥락과 연결된 번역 (예: "diff 파이프라인 개선이 새 Phase로 공식화됨") -- [ ] PASS: Trial 1보다 관련성 높은 인사이트 포함 -- [ ] NEUTRAL: Trial 1과 품질 차이 없음 (맥락이 효과 없음) -- [ ] FAIL: 맥락을 무시하고 Trial 1과 동일한 결과 - ---- - -### Trial 3: 맥락 기반 자율 시각화 (H3 검증) - -구조 트리를 제공하지 않고, 대화 맥락만으로 시각화를 생성하게 한다. - -**사전 준비**: 테스트 파일 초기화 -```bash -node "$VYNC_HOME/bin/vync.js" init poc-test -``` - -``` -Agent({ - description: "PoC Trial 3: autonomous visualization", - subagent_type: "vync-translator", - mode: "bypassPermissions", - prompt: `## 작업: Create -파일: - -## 대화 맥락 -Vync 프로젝트의 diff 파이프라인을 개선하는 논의가 진행 중이다. - -현재 문제: -- Sub-agent가 "기계적 JSON 변환기"일 뿐, 시각화 전략 판단 능력이 없음 -- 대화 맥락과 유저의 시각적 피드백(diff)이 Sub-agent에 전달되지 않음 -- diff 계산이 LLM 기반이라 비결정적이고 누락 위험 - -개선 방향: -- 프로그래밍적 diff 엔진 (코드): 정밀한 구조적 변경 감지 -- Sub-agent 입력 프로토콜 확장: 맥락 + diff + 지시 -- Sub-agent 역할 업그레이드: 시각화 전문가 (맥락 인지형) - -3단계 파이프라인: - Stage 1 — vync diff (코드): .lastread vs .vync 프로그래밍적 비교 - Stage 2 — Sub-agent (LLM): 맥락+diff → 시각화 판단 + 실행 + 번역 - Stage 3 — 메인 세션: Sub-agent prose + 대화 맥락 → 인사이트 - -## 지시 -이 논의 내용을 시각화해줘. 적절한 형식(mindmap/flowchart)과 구조를 네가 판단해서. -핵심 포인트를 포착하되, 과도한 세분화는 피해줘.` -}) -``` - -**판정 기준:** -- [ ] PASS: 적절한 시각화 유형 선택 (mindmap 또는 flowchart, 맥락에 맞게) -- [ ] PASS: 핵심 포인트 포착 (3단계 파이프라인, 현재 문제, 개선 방향 중 2개 이상) -- [ ] PASS: 과도하지 않은 세분화 (2-3단계 깊이) -- [ ] PASS: 유효한 .vync JSON 생성 (validate.js 통과) -- [ ] FAIL: 맥락을 무시하고 generic한 다이어그램 생성 -- [ ] FAIL: "구조를 제공해주세요" 같은 거부/요청 -- [ ] FAIL: 과도한 세분화 (4단계 이상, 20+ 노드) - ---- - -## 실행 순서 - -``` -1. Trial 1 실행 → 결과 기록 -2. Trial 2 실행 → 결과 기록 -3. Trial 1 vs Trial 2 비교 분석 -4. vync init poc-test (Trial 3 준비) -5. Trial 3 실행 → 결과 기록 -6. 전체 가설 판정 -7. 결과를 이 문서에 기록 -``` - ---- - -## 결과 기록 (PoC 실행 후 작성) - -### Trial 1 결과 -- Sub-agent 반환: 두 변경사항을 정확히 인식(추가 1, 수정 1). "게이트를 통과했다는 인식의 전환" + "완료의 여운 속에서 다음 관심이 이미 확정된 상태로 진입" — 단순 나열 아닌 사고 흐름 해석 -- H1 판정: **PASS** -- 비고: 맥락 없이도 의미적 번역 가능. 다만 해석이 추상적 수준에 머무름 (프로젝트 구체 맥락과 연결 부족) - -### Trial 2 결과 -- Sub-agent 반환: 동일 변경사항 인식 + "향후 로드맵의 추상적 항목(P2)에 머물지 않고, 현재 논의 중인 Sub-agent 개선을 Phase 10이라는 구체적 단위로 격상" — 대화 맥락과 직접 연결된 인사이트 -- H2 판정: **PASS** -- Trial 1 대비 차이: Trial 1은 추상적 해석("게이트 통과", "다음 관심 확정"), Trial 2는 대화 맥락 기반 구체적 해석("로드맵 항목이 아니라 지금 당장의 작업으로 인식"). 맥락이 관련성 높은 인사이트 생성에 명확히 기여 -- 비고: 맥락을 무시하지 않고 적극 활용. 특히 "Phase 10으로 격상"이라는 표현은 맥락 없이는 나올 수 없는 번역 - -### Trial 3 결과 -- Sub-agent 반환: mindmap 생성 — 루트 "Diff 파이프라인 개선" 아래 3개 카테고리(현재 문제, 3단계 파이프라인, 개선 방향) + 각 3개 리프 -- 생성된 시각화 유형: mindmap (적절한 선택 — 계층적 분류에 적합) -- 노드 수 / 깊이: 16개 노드 / 3~4단계 (루트→카테고리→리프, Stage에 설명 노드 추가 시 4단계) -- validate.js 결과: OK -- H3 판정: **PASS** -- 비고: 거부하지 않고 자율적으로 형식·구조 판단. 3개 핵심 포인트 모두 포착. 세분화 적절 (과도하지 않음). "구조를 제공해주세요" 같은 거부 없이 바로 실행 - -### 종합 판정 - -| 가설 | 결과 | 다음 행동 | -|------|------|-----------| -| H1: diff 의미적 번역 | **PASS** | 현재 Sub-agent도 diff 입력을 의미적으로 번역 가능. 프롬프트 수정 불필요 | -| H2: 맥락 힌트 효과 | **PASS** | 맥락 추가 시 번역 품질 유의미하게 향상. 입력 프로토콜에 맥락 필드 추가 확정 | -| H3: 자율 시각화 판단 | **PASS** | Sub-agent가 형식·구조를 자율 판단하여 유효한 시각화 생성. 역할 업그레이드 실현 가능 | - -**전체 결론**: 3개 가설 모두 PASS. 현재 vync-translator Sub-agent는 프롬프트 수정 없이도 (1) diff 의미 번역, (2) 맥락 활용, (3) 자율 시각화를 수행할 수 있음. Diff 파이프라인 재설계의 핵심 전제가 검증됨. -**다음 단계**: 설계 문서(`2026-03-11-diff-pipeline-redesign.md`) 기반으로 구현 착수 — (1) 프로그래밍적 diff 엔진 구현, (2) Sub-agent 입력 프로토콜에 맥락+diff 필드 추가, (3) 메인 세션→Sub-agent 호출 흐름 통합 - ---- - -## 주의사항 - -- Trial 1, 2는 `Read` 작업이므로 .vync 파일을 수정하지 않아야 함 -- Trial 3는 `Create` 작업이므로 poc-test.vync 파일이 생성됨 -- Trial 3 후 생성된 파일을 브라우저에서 확인하여 시각적 품질도 평가 -- PoC 파일(poc-test.vync)은 검증 후 삭제해도 무방 -- Sub-agent는 현재 `agents/vync-translator.md`를 그대로 사용 (프롬프트 미수정 상태) - → PoC는 "현재 Sub-agent가 새 입력 형식을 얼마나 잘 처리하는지" 검증 - → 결과에 따라 Sub-agent 프롬프트 수정 방향 결정 diff --git a/docs/plans/2026-03-11-diff-pipeline-redesign.md b/docs/plans/2026-03-11-diff-pipeline-redesign.md deleted file mode 100644 index 44d26f3..0000000 --- a/docs/plans/2026-03-11-diff-pipeline-redesign.md +++ /dev/null @@ -1,396 +0,0 @@ -# Diff Pipeline Redesign — Sub-agent를 시각화 전문가로 - -**Date**: 2026-03-11 -**Status**: 구현 완료 (Phase A/C/D DONE, PR #9) - ---- - -## 1. 현재 상태 분석 - -### Sub-agent(vync-translator)의 현재 역할 -**"기계적 JSON 변환기"** — 메인 세션이 모든 판단을 하고, Sub-agent는 변환만 수행 - -| 작업 | 메인 세션이 하는 일 | Sub-agent가 하는 일 | -|------|-------------------|-------------------| -| Create | 대화에서 구조 추출 → 트리 prose 정리 | prose → JSON 변환 | -| Read | /vync read 호출 | JSON → prose 요약 + LLM diff | -| Update | 수정 지시를 자연어로 정리 | 지시대로 JSON 수정 | - -### 3가지 Gap - -**Gap 1: 맥락 단절** -- Sub-agent는 대화 맥락을 받지 않음 -- "지금 무슨 논의 중인지" 모른 채 작업 -- 시각화 전략 판단 불가 (맥락이 없으므로) - -**Gap 2: Diff가 LLM 기반** -- Sub-agent가 두 JSON을 읽고 LLM으로 비교 -- 비결정적, 복잡한 변경에서 누락 가능 -- 정밀도 보장 불가 - -**Gap 3: 유저 피드백(diff)이 전달 안 됨** -- 유저가 브라우저에서 수정한 내용이 Create/Update 시 Sub-agent에 전달되지 않음 -- "유저가 이전에 뭘 바꿨는지" 모르고 작업 - ---- - -## 2. 개선 방향 - -### Sub-agent 역할 재정의 -**"기계적 JSON 변환기" → "시각화 전문가 (맥락 인지형)"** - -Sub-agent의 2가지 역할: -1. **시각화 전략 결정**: 맥락 + diff를 보고 "어떤 부분을 어떻게 시각화할지" 판단 -2. **시각화 실행**: 판단에 따라 .vync 파일 작성/수정 - -### 메인 세션 역할 변경 -| | 현재 | 개선 후 | -|--|------|---------| -| 메인 세션 | 구조 정리 + prose 트리 작성 | 대화 맥락 요약 + diff 실행 | -| Sub-agent | prose → JSON 변환 | 맥락 분석 → 시각화 판단 → 실행 | - -### 3단계 파이프라인 - -``` -Stage 1: vync diff (프로그래밍적, 코드) - .lastread vs .vync → 구조적 diff 텍스트 - 정확, 빠름, 결정적 - -Stage 2: Sub-agent (시각화 전문가) - 입력: 맥락 + diff + 지시 - 판단: "어떤 부분을 어떻게 시각화할지" - 실행: .vync 파일 작성/수정 - 출력: 의미적 요약 (prose) - -Stage 3: 메인 세션 - Sub-agent의 prose + 대화 맥락 → 인사이트/다음 행동 -``` - ---- - -## 3. 입력 프로토콜 재설계 - -### 현재 prompt 구조 -``` -## 작업: Create -타입: mindmap -파일: /path/to/file.vync - -## 구조 -- 프로젝트 (root) - - 기획 - - 개발 -``` - -### 개선된 prompt 구조 -``` -## 작업: Create -파일: /path/to/file.vync - -## 대화 맥락 -현재 TDD 도입과 배포 전략을 논의 중. 유저는 테스팅을 개발 프로세스에 -포함시키고 싶어하며, CI/CD 파이프라인도 관심 있음. - -## 유저 피드백 (diff) -(첫 생성이므로 없음) - -## 지시 -현재 논의 내용을 시각화해줘. 적절한 형식과 구조를 판단해서. -``` - -### 작업별 prompt 템플릿 - -**Create**: -``` -## 작업: Create -파일: - -## 대화 맥락 -<메인 세션이 요약한 현재 논의 상황, 2-5문장> - -## 유저 피드백 (diff) -없음 (첫 생성) - -## 지시 -<구체적 지시 or "맥락에 맞게 판단해서 시각화해줘"> -<선호하는 유형이 있으면: "mindmap 형식으로" 등> -``` - -**Read**: -``` -## 작업: Read -파일: - -## 대화 맥락 -<현재 논의 상황> - -## 유저 피드백 (diff) - -예: - 현재 구조: - 프로젝트 > [기획(인터뷰), 개발(FE, BE, 테스팅), 출시(마케팅, 배포)] - 변경사항: - Added: 테스팅 (under 개발), 출시, 마케팅 (under 출시) - Moved: 배포 from 프로젝트 → 출시 - Removed: 시장조사 (was under 기획) - -## 지시 -위 변경사항을 대화 맥락에 비춰 의미적으로 번역해줘. "유저의 생각이 어떻게 변화했는지"를 요약해줘. -``` - -**Update**: -``` -## 작업: Update -파일: - -## 대화 맥락 -<현재 논의 상황> - -## 유저 피드백 (diff) - - -## 지시 -<구체적 수정 지시 or "유저 피드백을 반영하여 적절히 보강해줘"> -``` - ---- - -## 4. 구현 변경 목록 - -### 4-1. `tools/cli/diff.ts` (신규) -프로그래밍적 diff 엔진. - -**기능:** -- `vync diff ` CLI 커맨드 -- .lastread vs .vync 비교 -- ID 기반 노드 매칭 -- 레이아웃 변경(points, width, height) 자동 무시 -- 현재 구조 트리 + 변경 목록 출력 - -**출력 포맷:** -``` -=== Vync Diff: plan.vync === - -현재 구조: - 프로젝트 - ├── 기획 (인터뷰) - ├── 개발 (FE, BE, 테스팅) - └── 출시 (마케팅, 배포) - -변경사항: - Added: 테스팅 (under 개발) - Added: 출시 (under 프로젝트) - Added: 마케팅 (under 출시) - Moved: 배포 — 프로젝트 → 출시 - Removed: 시장조사 (was under 기획) - Modified: "Phase 9" → "Phase 9 ✅" - -Snapshot updated. -``` - -**옵션:** -- `--no-snapshot`: diff만 보고 .lastread 갱신 안 함 -- 기본: diff 후 .lastread 자동 갱신 - -**비교 알고리즘:** -1. 양쪽 elements를 재귀적으로 flatten → Map -2. ID 집합 비교 → added, removed -3. 같은 ID의 text 비교 → modified -4. 같은 ID의 parentId 비교 → moved -5. points, width, height, manualWidth 등 레이아웃 필드 무시 - -### 4-2. `agents/vync-translator.md` (수정) - -> **PoC 결과 참고**: 현재 프롬프트가 새 입력 형식(맥락+diff)을 이미 잘 처리함 (3/3 PASS). -> 아래 변경은 **품질 안정화**(권장)이며, 미적용 시에도 기본 동작은 보장됨. - -**정체성 변경:** -``` -당신은 Vync 시각화 전문가입니다. -대화 맥락과 유저 피드백을 이해하고, -적절한 시각적 표현을 판단하여 .vync 파일을 작성/수정합니다. -``` - -**추가할 섹션:** -``` -## 시각화 판단 가이드 - -맥락에 따른 시각화 유형 선택: -- 계획/구조 정리 → mindmap -- 프로세스/흐름 → flowchart (geometry + arrow-line) -- 비교/분류 → mindmap with 병렬 가지 -- 관계/연결 → flowchart - -시각화 보강 원칙: -- 유저의 변경 의도를 존중: 추가한 것은 유지, 삭제한 것은 되살리지 않음 -- 맥락에서 빠진 항목이 있으면 보강 제안 가능 -- 과도한 세분화 지양: 2-3단계 깊이 권장 (부분적 4단계는 허용, 20노드 이내) -``` - -**Read 절차 변경:** -``` -### Read -1. 전달받은 diff 결과를 분석 (직접 JSON 비교하지 않음!) -2. 대화 맥락과 결합하여 "생각의 변화"를 의미적으로 번역 -3. 반환: 변화의 의미 요약 - 예: "개발 프로세스에 테스팅이 추가되고, 기획의 리서치가 축소됨. - 실행 중심으로 구조가 이동하는 방향." -4. 스냅샷 갱신은 vync diff가 이미 처리 (sub-agent에서 안 함) -``` - -**Create 절차 변경:** -``` -### Create -1. 대화 맥락 분석 → 시각화 유형 + 구조 판단 -2. 해당 타입의 참조 문서 Read -3. ID 생성 → PlaitElement[] 구성 -4. .vync 파일 Write + 검증 + 서버 열기 -5. 스냅샷 생성 (.lastread) -6. 반환: 무엇을 어떤 구조로 시각화했는지 요약 -``` - -**Update 절차 변경:** -``` -### Update -1. 대화 맥락 + diff 이해 -2. .vync 파일 Read (현재 상태) -3. 맥락과 diff를 고려하여 수정 판단 + 실행 -4. 검증 + 서버 열기 + 스냅샷 갱신 -5. 반환: 무엇을 어떻게 변경했는지 요약 -``` - -### 4-3. `commands/vync.md` (수정) - -**변경 1: diff 서브커맨드 추가** -``` -### CLI (direct execution) -- `diff [file]` — 마지막 동기화 이후 변경사항 표시 (프로그래밍적) - - `--no-snapshot` — diff만 보고 스냅샷 갱신 안 함 -``` - -**변경 2: Read 흐름 재설계** -``` -### Read -1. 파일경로 해결 -2. `vync diff ` 실행 (Bash) → 프로그래밍적 diff 결과 획득 -3. 대화 맥락 요약 (2-5문장) -4. Agent tool 호출: - Agent({ - description: "Vync read + translate diff", - subagent_type: "vync-translator", - mode: "bypassPermissions", - prompt: "## 작업: Read\n파일: \n\n## 대화 맥락\n<맥락>\n\n## 유저 피드백 (diff)\n\n\n## 지시\n위 변경사항을 대화 맥락에 비춰 의미적으로 번역해줘." - }) -5. Sub-agent의 의미적 번역을 대화에 통합 -``` - -**변경 3: Create 흐름 재설계** -``` -### Create -1. 파일경로 해결 (없으면 vync init 먼저) -2. 대화 맥락 요약 (2-5문장) -3. Agent tool 호출: - Agent({ - description: "Vync create visualization", - subagent_type: "vync-translator", - mode: "bypassPermissions", - prompt: "## 작업: Create\n파일: \n\n## 대화 맥락\n<맥락>\n\n## 지시\n<지시 or 맥락에 맞게 판단>" - }) -4. Sub-agent의 시각화 요약을 사용자에게 전달 -``` - -**변경 4: Update 흐름 재설계** -``` -### Update -1. 파일경로 해결 -2. `vync diff ` 실행 (유저 수정이 있었을 수 있으므로) -3. 대화 맥락 + diff + 지시 정리 -4. Agent tool 호출: - Agent({ - description: "Vync update visualization", - subagent_type: "vync-translator", - mode: "bypassPermissions", - prompt: "## 작업: Update\n파일: \n\n## 대화 맥락\n<맥락>\n\n## 유저 피드백 (diff)\n\n\n## 지시\n<지시>" - }) -5. Sub-agent의 변경 요약을 사용자에게 전달 -``` - ---- - -## 5. PoC 설계 — 완료 (2026-03-11) - -> **실행 결과**: `2026-03-11-diff-pipeline-poc.md` (실행 가이드), `2026-03-11-diff-pipeline-poc-results.md` (결과 보고) -> -> **실제 실행과의 차이**: -> - 테스트 데이터: 실제 파일 diff 대신 **합성 diff 시나리오** 사용 (사유: .vync와 .lastread 콘텐츠 동일) -> - 실행 순서: Phase A(diff 엔진) 미구현 상태에서 Phase B(PoC) 선행 실행 → 합성 diff로 가능했음 -> - 결과: **H1 PASS, H2 PASS, H3 PASS** → D-015, D-016 확정 - -### 가설 - -**H1: Sub-agent가 프로그래밍적 diff를 받아서 의미적 번역이 가능한가?** → **PASS** -**H2: 대화 맥락 힌트가 번역/판단 품질을 의미있게 향상시키는가?** → **PASS** -**H3: Sub-agent가 맥락만으로 시각화 전략을 자율적으로 결정할 수 있는가?** → **PASS** - ---- - -## 6. 구현 순서 - -### Phase B: PoC 실행 — ✅ 완료 (2026-03-11) -4. ~~실제 데이터로 diff 엔진 동작 확인~~ → 합성 diff로 검증 -5. Trial 1, 2, 3 실행 → 3/3 PASS -6. 결과 분석 + 가설 판정 → D-015, D-016 확정 - -### Phase A: 프로그래밍적 diff 엔진 — ✅ 완료 (2026-03-11) -1. `tools/cli/diff.ts` 작성 (비교 알고리즘 + CLI 인터페이스) -2. `tools/cli/main.ts`에 diff 서브커맨드 등록 -3. 단위 테스트 (`tools/cli/__tests__/diff.test.ts`) — 15개 PASS - -### Phase C: Sub-agent 프롬프트 재설계 (품질 안정화) — ✅ 완료 (2026-03-11) -7. `agents/vync-translator.md` 수정 (정체성 + 시각화 판단 가이드 + 절차) -8. `commands/vync.md` 수정 (diff 서브커맨드 + Read/Create/Update 흐름 재설계) - -### Phase D: 통합 테스트 — ✅ 완료 (2026-03-11) -9. 전체 테스트 67개 PASS (13 파일), CLI 수동 검증 PASS -10. 플러그인 캐시 동기화 (`bash .claude-plugin/install.sh`) ✅ -11. PR #9: feat/diff-pipeline → develop -12. E2E 통합 검증 6/6 PASS: - - T1: Create + 스냅샷 생성 ✅ - - T2: 브라우저 수정 + Diff 감지 (Added/Removed/Modified) ✅ - - T3: Read + 의미 번역 (맥락 결합 인사이트) ✅ - - T4: Update + 맥락 기반 수정 (기존 유지 + 추가) ✅ - - T5: 연속 Diff 스냅샷 일관성 ✅ - - T6: --no-snapshot 옵션 3단계 검증 ✅ - - 부수 수정: formatDiffResult --no-snapshot 표시 버그 fix - ---- - -## 7. 설계 결정 - -### D-015: Diff Pipeline Hybrid Architecture -- **결정**: 프로그래밍적 diff(코드) + Sub-agent 의미 번역(LLM) + 메인 세션 맥락 해석 -- **대안 1**: 전부 LLM (현재) — 정밀도 부족 -- **대안 2**: 전부 코드 + 메인 세션 직접 해석 — 메인 세션이 구조 해석 부담 -- **근거**: 각 단계가 가장 잘하는 것을 담당. 코드=정밀계산, LLM=의미번역, 메인=맥락해석 -- **재검토 조건**: Sub-agent 번역 품질이 PoC에서 불충분할 경우 -- **PoC 결과**: H1(diff 번역) PASS, H2(맥락 효과) PASS → **확정** (2026-03-11) - -### D-016: Sub-agent 역할 확장 (시각화 전문가) -- **결정**: 맥락 인지형 시각화 전문가. 시각화 전략 판단 + 실행. -- **대안**: 기계적 변환기 유지 (현재) — 메인 세션 부담 큼 -- **근거**: 메인 세션은 대화에 집중, Sub-agent는 시각화에 집중. 역할 분리 명확화. -- **입력**: 대화 맥락 + 프로그래밍적 diff + 지시 -- **재검토 조건**: 자율 판단 품질이 PoC에서 불충분할 경우 -- **PoC 결과**: H3(자율 시각화) PASS — mindmap 자율 선택, 16노드/3단계, validate 통과 → **확정** (2026-03-11) - ---- - -## 8. 불필요 제거 확인 - -| 항목 | 판단 | 이유 | -|------|------|------| -| 변경 이력 스택 | 불필요 | 세션 내 단일 스냅샷 충분 | -| Push 알림 | 불필요 | 유저 요청 시만 diff | -| 고아 .lastread 정리 | 불필요 | 파일 크기 미미 | -| 시간/세션 기반 스냅샷 | 불필요 | 세션 내 생각 동기화가 목적 | -| Read의 LLM JSON 비교 | 제거 | 프로그래밍적 diff로 대체 | diff --git a/docs/plans/2026-03-12-fix-tab-add-button.md b/docs/plans/2026-03-12-fix-tab-add-button.md new file mode 100644 index 0000000..4ff0a90 --- /dev/null +++ b/docs/plans/2026-03-12-fix-tab-add-button.md @@ -0,0 +1,676 @@ +# Fix: Tab Bar "+" Button Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** "+" 버튼이 정상 동작하여, 사용자가 디스크의 미등록 `.vync` 파일을 브라우저에서 직접 열 수 있게 한다. + +**Architecture:** 두 가지 독립 버그 수정 — (1) CSS overflow 클리핑으로 드롭다운이 보이지 않는 문제, (2) 드롭다운에 파일이 없는 문제. (1)은 탭 바 DOM 구조 분리로, (2)는 서버 `GET /api/files/discover` 엔드포인트 + 프론트엔드 연동으로 해결. + +**Tech Stack:** TypeScript, React 19, Express, vitest + +--- + +## Root Cause + +### Bug 1 (Primary): CSS overflow 클리핑 + +``` +.vync-tab-bar { height: 36px; overflow-x: auto; } + └─ CSS 스펙: overflow-x: auto → overflow-y도 auto로 강제 + └─ .vync-tab-dropdown { position: absolute; top: 100%; } + └─ top: 100% = 36px = 탭 바 밖 → 스크롤 컨테이너에 의해 클리핑됨 + └─ 드롭다운이 렌더링되지만 사용자에게 보이지 않음 +``` + +### Bug 2 (Secondary): 파일 목록 항상 비어있음 + +``` +app.tsx: hub-file-registered → registeredFiles + tabs 모두에 추가 + └─ unopenedFiles = registeredFiles - tabs = 항상 빈 배열 + └─ 드롭다운: "No more files" 메시지만 표시 +``` + +--- + +## File Structure + +| File | Responsibility | Change | +|------|----------------|--------| +| `apps/web/src/app/tab-bar.tsx` | TabBar 컴포넌트 | DOM 분리 + 드롭다운 두 섹션 + 새 props | +| `apps/web/src/app/tab-bar.scss` | TabBar 스타일 | scroll 영역 분리 + 섹션 헤더 + dropdown max-height | +| `apps/web/src/app/app.tsx` | App 상태 관리 | discovery state + handlers + TabBar props | +| `tools/server/server.ts` | Express 서버 | `GET /api/files/discover` 엔드포인트 | +| `tools/server/security.ts` | 보안 유틸 | 변경 없음 (`getAllowedDirs` 이미 export) | +| `tools/server/__tests__/discover.test.ts` | 디스커버리 테스트 | 신규 생성 | + +--- + +## Chunk 1: CSS overflow 클리핑 수정 + 서버 디스커버리 엔드포인트 + +### Task 1: CSS overflow 클리핑 수정 + +**Files:** +- Modify: `apps/web/src/app/tab-bar.tsx` +- Modify: `apps/web/src/app/tab-bar.scss` + +- [ ] **Step 1: SCSS — 탭 바에서 overflow를 scroll 영역으로 분리** + +`apps/web/src/app/tab-bar.scss` 수정: + +```scss +.vync-tab-bar { + display: flex; + align-items: stretch; + height: 36px; + background: #f0f0f0; + border-bottom: 1px solid #ddd; + flex-shrink: 0; + /* overflow-x: auto 제거 — .vync-tab-scroll로 이동 */ +} + +.vync-tab-scroll { + display: flex; + align-items: stretch; + flex: 1; + min-width: 0; + overflow-x: auto; + &::-webkit-scrollbar { height: 0; } +} +``` + +기존 `.vync-tab-bar`에서 `overflow-x: auto`와 `&::-webkit-scrollbar` 삭제. 나머지 룰(`.vync-tab`, `.vync-tab__close`, `.vync-tab-add`, `.vync-tab-dropdown`, etc)은 그대로 유지. + +- [ ] **Step 2: TSX — 탭 바 DOM 구조에 scroll wrapper 추가** + +`apps/web/src/app/tab-bar.tsx`의 return문 수정: + +```tsx +return ( +
+
+ {tabs.map((tab) => ( +
onTabClick(tab.filePath)} + > + {tab.label} + +
+ ))} +
+
+ setDropdownOpen(!dropdownOpen)}>+ + {dropdownOpen && ( +
+ {unopenedFiles.length > 0 ? ( + unopenedFiles.map((fp) => { + const parts = fp.split('/'); + const label = parts[parts.length - 1] || fp; + return ( +
{ + onAddFile(fp); + setDropdownOpen(false); + }} + > + {label} +
+ ); + }) + ) : ( +
+ No more files. +
+ Use vync open to register new files. +
+ )} +
+ )} +
+
+); +``` + +핵심 변경: `{tabs.map(...)}`을 `
` 안에 넣고, `.vync-tab-add`는 그 바깥에 둔다. + +- [ ] **Step 3: 브라우저에서 드롭다운 표시 확인** + +Run: `npm run dev:server` + +브라우저에서 `http://localhost:3100/?file=` 열고 "+" 클릭 → 드롭다운이 탭 바 아래에 정상 표시되는지 확인. "No more files" 메시지가 보이면 Bug 1 수정 완료. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/app/tab-bar.tsx apps/web/src/app/tab-bar.scss +git commit -m "fix(ui): separate tab scroll container to prevent dropdown clipping + +overflow-x: auto on .vync-tab-bar forced overflow-y: auto per CSS spec, +clipping the absolutely positioned dropdown. Split into .vync-tab-scroll +(overflow-x: auto) and keep .vync-tab-add outside the scroll container." +``` + +--- + +### Task 2: 서버 디스커버리 엔드포인트 — 테스트 먼저 + +**Files:** +- Create: `tools/server/__tests__/discover.test.ts` + +- [ ] **Step 1: 디스커버리 테스트 작성** + +`tools/server/__tests__/discover.test.ts` 생성: + +```typescript +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { addAllowedDir, clearAllowedDirs } from '../security.js'; + +const VYNC_STUB = JSON.stringify({ + version: 1, + viewport: { zoom: 1, x: 0, y: 0 }, + elements: [], +}); + +describe('GET /api/files/discover', () => { + const tmpDir = path.join(os.tmpdir(), `vync-discover-${Date.now()}`); + let shutdownFn: (() => Promise) | null = null; + + afterEach(async () => { + if (shutdownFn) { + await shutdownFn(); + shutdownFn = null; + } + clearAllowedDirs(); + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }); + + async function startWithFile(filePath: string) { + addAllowedDir(path.dirname(filePath)); + const { startServer } = await import('../server.js'); + const port = 3200 + Math.floor(Math.random() * 100); + const result = await startServer({ + port, + mode: 'production', + initialFile: filePath, + }); + shutdownFn = result.shutdown; + return { port, ...result }; + } + + it('discovers unregistered .vync files in allowed directory', async () => { + await fs.mkdir(tmpDir, { recursive: true }); + const registered = path.join(tmpDir, 'a.vync'); + const unregistered = path.join(tmpDir, 'b.vync'); + await fs.writeFile(registered, VYNC_STUB); + await fs.writeFile(unregistered, VYNC_STUB); + + const { port } = await startWithFile(registered); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + expect(res.ok).toBe(true); + const body = await res.json(); + const realUnregistered = await fs.realpath(unregistered); + expect(body.files).toContain(realUnregistered); + // registered file should NOT appear + const realRegistered = await fs.realpath(registered); + expect(body.files).not.toContain(realRegistered); + }); + + it('ignores non-.vync files', async () => { + await fs.mkdir(tmpDir, { recursive: true }); + const vyncFile = path.join(tmpDir, 'a.vync'); + const txtFile = path.join(tmpDir, 'notes.txt'); + await fs.writeFile(vyncFile, VYNC_STUB); + await fs.writeFile(txtFile, 'hello'); + + const { port } = await startWithFile(vyncFile); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + const body = await res.json(); + expect(body.files.every((f: string) => f.endsWith('.vync'))).toBe(true); + }); + + it('scans .vync/ subdirectory of allowed dir', async () => { + const subDir = path.join(tmpDir, '.vync'); + await fs.mkdir(subDir, { recursive: true }); + const parentFile = path.join(tmpDir, 'main.vync'); + const subFile = path.join(subDir, 'sub.vync'); + await fs.writeFile(parentFile, VYNC_STUB); + await fs.writeFile(subFile, VYNC_STUB); + + const { port } = await startWithFile(parentFile); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + const body = await res.json(); + const realSub = await fs.realpath(subFile); + expect(body.files).toContain(realSub); + }); + + it('returns empty array when no unregistered files exist', async () => { + await fs.mkdir(tmpDir, { recursive: true }); + const onlyFile = path.join(tmpDir, 'only.vync'); + await fs.writeFile(onlyFile, VYNC_STUB); + + const { port } = await startWithFile(onlyFile); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + const body = await res.json(); + expect(body.files).toEqual([]); + }); + + it('handles non-existent scan directories gracefully', async () => { + await fs.mkdir(tmpDir, { recursive: true }); + const file = path.join(tmpDir, 'a.vync'); + await fs.writeFile(file, VYNC_STUB); + + // Add a non-existent dir to allowedDirs + addAllowedDir('/tmp/does-not-exist-' + Date.now()); + + const { port } = await startWithFile(file); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + expect(res.ok).toBe(true); + // Should not crash + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 → 실패 확인** + +Run: `npx vitest run tools/server/__tests__/discover.test.ts` + +Expected: FAIL — `GET /api/files/discover` 엔드포인트가 아직 없으므로 404 또는 fetch 에러. + +- [ ] **Step 3: Commit (failing tests)** + +```bash +git add tools/server/__tests__/discover.test.ts +git commit -m "test: add failing tests for GET /api/files/discover endpoint" +``` + +--- + +### Task 3: 서버 디스커버리 엔드포인트 — 구현 + +**Files:** +- Modify: `tools/server/server.ts:6-10` (import 수정) +- Modify: `tools/server/server.ts:125-126` (엔드포인트 추가) + +- [ ] **Step 1: import에 `getAllowedDirs` 추가, `fs` import 추가** + +`tools/server/server.ts` 상단 수정: + +```typescript +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import express from 'express'; +import { createWsServer } from './ws-handler.js'; +import { FileRegistry } from './file-registry.js'; +import { + addAllowedDir, + createHostGuard, + getAllowedDirs, + validateFilePath, +} from './security.js'; +import type { VyncFile } from '@vync/shared'; +``` + +변경: `import fs from 'node:fs/promises';` 추가 (line 3), `getAllowedDirs` import 추가 (line 10). + +- [ ] **Step 2: `GET /api/files/discover` 엔드포인트 추가** + +`tools/server/server.ts`의 `DELETE /api/files` 핸들러 다음, `// --- Sync API` 주석 전에 추가 (line 126 부근): + +```typescript + // --- File discovery API --- + app.get('/api/files/discover', async (_req, res) => { + try { + const registered = new Set(registry.listFiles()); + const scanDirs = new Set(); + for (const dir of getAllowedDirs()) { + scanDirs.add(dir); + scanDirs.add(path.join(dir, '.vync')); + } + const discovered: string[] = []; + const MAX_RESULTS = 100; + for (const dir of scanDirs) { + if (discovered.length >= MAX_RESULTS) break; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (discovered.length >= MAX_RESULTS) break; + if (!entry.isFile() || !entry.name.endsWith('.vync')) continue; + const real = await fs.realpath(path.join(dir, entry.name)).catch( + () => null + ); + if (real && !registered.has(real)) { + discovered.push(real); + } + } + } catch { + /* directory doesn't exist or not readable */ + } + } + res.json({ files: [...new Set(discovered)] }); + } catch (err: any) { + console.error('[vync] Discovery error:', err); + res.status(500).json({ error: 'Discovery failed' }); + } + }); +``` + +- [ ] **Step 3: 테스트 실행 → 통과 확인** + +Run: `npx vitest run tools/server/__tests__/discover.test.ts` + +Expected: 5/5 PASS. + +- [ ] **Step 4: 전체 테스트 실행** + +Run: `npm test` + +Expected: 기존 테스트 + 신규 5개 모두 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tools/server/server.ts tools/server/__tests__/discover.test.ts +git commit -m "feat(server): add GET /api/files/discover endpoint + +Scans allowedDirs and their .vync/ subdirectories for unregistered +.vync files. Returns up to 100 discovered file paths. Enables the +frontend '+' button to show files available for opening." +``` + +--- + +## Chunk 2: 프론트엔드 드롭다운 연동 + +### Task 4: App + TabBar discovery 연동 (원자적 변경) + +> Task 4는 app.tsx와 tab-bar.tsx를 함께 수정한다. 분리하면 TypeScript 컴파일 에러가 발생하므로 (app.tsx가 아직 없는 TabBar props를 전달) 반드시 하나의 커밋으로 묶는다. + +**Files:** +- Modify: `apps/web/src/app/app.tsx` +- Modify: `apps/web/src/app/tab-bar.tsx` +- Modify: `apps/web/src/app/tab-bar.scss` + +- [ ] **Step 1: TabBar props 인터페이스 확장** + +`apps/web/src/app/tab-bar.tsx`의 `TabBarProps` 수정: + +```typescript +interface TabBarProps { + tabs: TabInfo[]; + activeFilePath: string | null; + registeredFiles: string[]; + discoveredFiles: string[]; + onTabClick: (filePath: string) => void; + onTabClose: (filePath: string) => void; + onAddFile: (filePath: string) => void; + onDiscoverFile: (filePath: string) => void; + onDropdownOpen: () => void; +} +``` + +destructure에도 추가: + +```typescript +export function TabBar({ + tabs, + activeFilePath, + registeredFiles, + discoveredFiles, + onTabClick, + onTabClose, + onAddFile, + onDiscoverFile, + onDropdownOpen, +}: TabBarProps) { +``` + +- [ ] **Step 2: App에 discovery state와 handlers 추가** + +`apps/web/src/app/app.tsx`에 다음 추가: + +state 선언 (line 33 이후, `tabsRef` 다음): + +```typescript +const [discoveredFiles, setDiscoveredFiles] = useState([]); +``` + +handlers (line 127 `handleAddFile` 다음): + +```typescript +const handleDropdownOpen = useCallback(async () => { + try { + const res = await fetch('/api/files/discover'); + if (res.ok) { + const data = await res.json(); + setDiscoveredFiles(data.files || []); + } + } catch { + setDiscoveredFiles([]); + } +}, []); + +const handleDiscoverFile = useCallback(async (filePath: string) => { + try { + const res = await fetch('/api/files', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filePath }), + }); + if (res.ok) { + const data = await res.json(); + handleAddFile(data.filePath); // 서버 검증된 경로 사용 + 즉시 탭 추가 + setDiscoveredFiles([]); + } + } catch (err) { + console.error('[vync] Failed to register discovered file:', err); + } +}, [handleAddFile]); +``` + +TabBar에 새 props 전달: + +```tsx + +``` + +- [ ] **Step 3: TabBar "+" 클릭 시 onDropdownOpen 호출 + 드롭다운 두 섹션 렌더링** + +`` onClick 수정: + +```tsx + { + const willOpen = !dropdownOpen; + setDropdownOpen(willOpen); + if (willOpen) onDropdownOpen(); + }} +> + + + +``` + +드롭다운 내용을 두 섹션으로 교체 (기존 `{dropdownOpen && (...)}` 블록 전체 교체): + +```tsx +{dropdownOpen && ( +
+ {unopenedFiles.length > 0 && ( + <> +
Reopen
+ {unopenedFiles.map((fp) => { + const parts = fp.split('/'); + const label = parts[parts.length - 1] || fp; + return ( +
{ + onAddFile(fp); + setDropdownOpen(false); + }} + > + {label} +
+ ); + })} + + )} + {discoveredFiles.length > 0 && ( + <> +
Open
+ {discoveredFiles.map((fp) => { + const parts = fp.split('/'); + const label = parts[parts.length - 1] || fp; + return ( +
{ + onDiscoverFile(fp); + setDropdownOpen(false); + }} + > + {label} +
+ ); + })} + + )} + {unopenedFiles.length === 0 && discoveredFiles.length === 0 && ( +
+ No files found. +
+ Use vync open <file> to add files. +
+ )} +
+)} +``` + +- [ ] **Step 4: 섹션 헤더 스타일 + 드롭다운 max-height 추가** + +`apps/web/src/app/tab-bar.scss`에 추가: + +```scss +.vync-tab-dropdown { + /* 기존 스타일에 추가 */ + max-height: 300px; + overflow-y: auto; +} + +.vync-tab-dropdown__section { + padding: 4px 12px; + font-size: 11px; + color: #999; + text-transform: uppercase; + letter-spacing: 0.5px; + &:not(:first-child) { + border-top: 1px solid #eee; + margin-top: 2px; + padding-top: 6px; + } +} +``` + +- [ ] **Step 5: TypeScript 컴파일 확인** + +Run: `npx tsc --noEmit` + +Expected: 에러 없음. + +- [ ] **Step 6: 전체 테스트 실행** + +Run: `npm test` + +Expected: 전체 PASS (기존 + discover 테스트). + +- [ ] **Step 7: Commit (app.tsx + tab-bar.tsx + tab-bar.scss 원자적)** + +```bash +git add apps/web/src/app/app.tsx apps/web/src/app/tab-bar.tsx apps/web/src/app/tab-bar.scss +git commit -m "feat(ui): connect file discovery to tab bar dropdown + +App fetches GET /api/files/discover on '+' click, passes results +to TabBar. Dropdown shows 'Reopen' (closed tabs) and 'Open' +(unregistered .vync files) sections. Uses handleAddFile with +server-validated path for reliable tab creation." +``` + +--- + +### Task 6: E2E 수동 검증 + +- [ ] **Step 1: 서버 시작 + 테스트 파일 준비** + +```bash +# 테스트 디렉토리에 .vync 파일 2개 생성 +mkdir -p /tmp/vync-test +echo '{"version":1,"viewport":{"zoom":1,"x":0,"y":0},"elements":[]}' > /tmp/vync-test/first.vync +echo '{"version":1,"viewport":{"zoom":1,"x":0,"y":0},"elements":[]}' > /tmp/vync-test/second.vync + +# 서버 시작 (first.vync만 등록) +npx tsx tools/server/server.ts /tmp/vync-test/first.vync +``` + +- [ ] **Step 2: 6개 시나리오 확인** + +| # | Action | Expected | +|---|--------|----------| +| 1 | "+" 클릭 | 드롭다운이 탭 바 아래에 **보임** (Bug 1 수정) | +| 2 | 드롭다운 내용 | "Open" 섹션에 `second.vync` 표시 (Bug 2 수정) | +| 3 | `second.vync` 클릭 | 탭에 추가, 활성화, 보드 로드 | +| 4 | 다시 "+" 클릭 | `second.vync` 더 이상 안 보임 (이미 등록) | +| 5 | `first.vync` 탭 × 닫기 | 탭 제거 | +| 6 | "+" 클릭 | "Reopen" 섹션에 `first.vync` 표시 | + +- [ ] **Step 3: 정리 + 최종 Commit** + +```bash +rm -rf /tmp/vync-test +``` + +최종 커밋이 필요한 경우만 (모든 변경이 이미 커밋되었을 수 있음). + +--- + +## Security Notes + +- `getAllowedDirs()` 범위 내에서만 스캔 (사용자가 `vync open`으로 열었던 디렉토리) +- 재귀 탐색 없음 (1단계만) +- `.vync` 확장자만 반환 +- 100개 결과 제한 +- 발견된 파일 클릭 → 기존 `POST /api/files` → `validateFilePath` 이중 검증 diff --git a/examples/flowchart.vync b/examples/flowchart.vync deleted file mode 100644 index 326c6a4..0000000 --- a/examples/flowchart.vync +++ /dev/null @@ -1,68 +0,0 @@ -{ - "version": 1, - "viewport": { "zoom": 1, "x": 0, "y": 0 }, - "elements": [ - { - "id": "rByjr", - "type": "geometry", - "shape": "terminal", - "points": [[0, 0], [160, 60]], - "text": { "children": [{ "text": "Start" }], "align": "center" }, - "children": [] - }, - { - "id": "SbExs", - "type": "geometry", - "shape": "process", - "points": [[0, 120], [160, 180]], - "text": { "children": [{ "text": "Process Data" }], "align": "center" }, - "children": [] - }, - { - "id": "QwmcN", - "type": "geometry", - "shape": "terminal", - "points": [[0, 240], [160, 300]], - "text": { "children": [{ "text": "End" }], "align": "center" }, - "children": [] - }, - { - "id": "CCJjh", - "type": "arrow-line", - "shape": "elbow", - "source": { - "marker": "none", - "boundId": "rByjr", - "connection": [0.5, 1] - }, - "target": { - "marker": "arrow", - "boundId": "SbExs", - "connection": [0.5, 0] - }, - "points": [[80, 60], [80, 120]], - "texts": [], - "opacity": 1, - "children": [] - }, - { - "id": "mJDnh", - "type": "arrow-line", - "shape": "elbow", - "source": { - "marker": "none", - "boundId": "SbExs", - "connection": [0.5, 1] - }, - "target": { - "marker": "arrow", - "boundId": "QwmcN", - "connection": [0.5, 0] - }, - "points": [[80, 180], [80, 240]], - "texts": [], - "opacity": 1, - "children": [] - } - ] -} diff --git a/examples/mindmap.vync b/examples/mindmap.vync deleted file mode 100644 index 6d59aea..0000000 --- a/examples/mindmap.vync +++ /dev/null @@ -1,55 +0,0 @@ -{ - "version": 1, - "viewport": { "zoom": 1, "x": 0, "y": 0 }, - "elements": [ - { - "id": "yhrcC", - "type": "mindmap", - "data": { "topic": { "children": [{ "text": "Project Plan" }] } }, - "children": [ - { - "id": "yZsnX", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Design" }] } }, - "children": [ - { - "id": "msnrf", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Architecture" }] } }, - "children": [] - }, - { - "id": "WHZaW", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Data Model" }] } }, - "children": [] - } - ] - }, - { - "id": "QPyWh", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Implementation" }] } }, - "children": [ - { - "id": "PNYAS", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Backend" }] } }, - "children": [] - }, - { - "id": "BWYED", - "type": "mind_child", - "data": { "topic": { "children": [{ "text": "Frontend" }] } }, - "children": [] - } - ] - } - ], - "width": 100, - "height": 50, - "points": [[0, 0]], - "isRoot": true - } - ] -} diff --git a/tools/electron/main.ts b/tools/electron/main.ts index b42dc1c..7dc6267 100644 --- a/tools/electron/main.ts +++ b/tools/electron/main.ts @@ -38,7 +38,7 @@ app.whenReady().then(async () => { if (!filePath) { const result = await dialog.showOpenDialog({ filters: [{ name: 'Vync Canvas', extensions: ['vync'] }], - properties: ['openFile'], + properties: ['openFile', 'showHiddenFiles'], }); if (result.canceled || result.filePaths.length === 0) { app.quit(); diff --git a/tools/server/__tests__/discover.test.ts b/tools/server/__tests__/discover.test.ts new file mode 100644 index 0000000..ad60ada --- /dev/null +++ b/tools/server/__tests__/discover.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { addAllowedDir, clearAllowedDirs } from '../security.js'; + +const VYNC_STUB = JSON.stringify({ + version: 1, + viewport: { zoom: 1, x: 0, y: 0 }, + elements: [], +}); + +describe('GET /api/files/discover', () => { + const tmpDir = path.join(os.tmpdir(), `vync-discover-${Date.now()}`); + let shutdownFn: (() => Promise) | null = null; + + afterEach(async () => { + if (shutdownFn) { + await shutdownFn(); + shutdownFn = null; + } + clearAllowedDirs(); + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + }); + + async function startWithFile(filePath: string) { + addAllowedDir(path.dirname(filePath)); + const { startServer } = await import('../server.js'); + const port = 3200 + Math.floor(Math.random() * 100); + const result = await startServer({ + port, + mode: 'production', + initialFile: filePath, + }); + shutdownFn = result.shutdown; + return { port, ...result }; + } + + it('discovers unregistered .vync files in allowed directory', async () => { + await fs.mkdir(tmpDir, { recursive: true }); + const registered = path.join(tmpDir, 'a.vync'); + const unregistered = path.join(tmpDir, 'b.vync'); + await fs.writeFile(registered, VYNC_STUB); + await fs.writeFile(unregistered, VYNC_STUB); + + const { port } = await startWithFile(registered); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + expect(res.ok).toBe(true); + const body = await res.json(); + const realUnregistered = await fs.realpath(unregistered); + expect(body.files).toContain(realUnregistered); + // registered file should NOT appear + const realRegistered = await fs.realpath(registered); + expect(body.files).not.toContain(realRegistered); + }); + + it('ignores non-.vync files', async () => { + await fs.mkdir(tmpDir, { recursive: true }); + const vyncFile = path.join(tmpDir, 'a.vync'); + const txtFile = path.join(tmpDir, 'notes.txt'); + await fs.writeFile(vyncFile, VYNC_STUB); + await fs.writeFile(txtFile, 'hello'); + + const { port } = await startWithFile(vyncFile); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + const body = await res.json(); + expect(body.files.every((f: string) => f.endsWith('.vync'))).toBe(true); + }); + + it('scans .vync/ subdirectory of allowed dir', async () => { + const subDir = path.join(tmpDir, '.vync'); + await fs.mkdir(subDir, { recursive: true }); + const parentFile = path.join(tmpDir, 'main.vync'); + const subFile = path.join(subDir, 'sub.vync'); + await fs.writeFile(parentFile, VYNC_STUB); + await fs.writeFile(subFile, VYNC_STUB); + + const { port } = await startWithFile(parentFile); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + const body = await res.json(); + const realSub = await fs.realpath(subFile); + expect(body.files).toContain(realSub); + }); + + it('returns empty array when no unregistered files exist', async () => { + await fs.mkdir(tmpDir, { recursive: true }); + const onlyFile = path.join(tmpDir, 'only.vync'); + await fs.writeFile(onlyFile, VYNC_STUB); + + const { port } = await startWithFile(onlyFile); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + const body = await res.json(); + expect(body.files).toEqual([]); + }); + + it('handles non-existent scan directories gracefully', async () => { + await fs.mkdir(tmpDir, { recursive: true }); + const file = path.join(tmpDir, 'a.vync'); + await fs.writeFile(file, VYNC_STUB); + + // Add a non-existent dir to allowedDirs + addAllowedDir('/tmp/does-not-exist-' + Date.now()); + + const { port } = await startWithFile(file); + + const res = await fetch(`http://localhost:${port}/api/files/discover`); + expect(res.ok).toBe(true); + // Should not crash + }); +}); diff --git a/tools/server/server.ts b/tools/server/server.ts index 9e2757b..c247667 100644 --- a/tools/server/server.ts +++ b/tools/server/server.ts @@ -1,11 +1,13 @@ import http from 'node:http'; import path from 'node:path'; +import fs from 'node:fs/promises'; import express from 'express'; import { createWsServer } from './ws-handler.js'; import { FileRegistry } from './file-registry.js'; import { addAllowedDir, createHostGuard, + getAllowedDirs, validateFilePath, } from './security.js'; import type { VyncFile } from '@vync/shared'; @@ -124,6 +126,42 @@ export async function startServer( } }); + // --- File discovery API --- + app.get('/api/files/discover', async (_req, res) => { + try { + const registered = new Set(registry.listFiles()); + const scanDirs = new Set(); + for (const dir of getAllowedDirs()) { + scanDirs.add(dir); + scanDirs.add(path.join(dir, '.vync')); + } + const discovered: string[] = []; + const MAX_RESULTS = 100; + for (const dir of scanDirs) { + if (discovered.length >= MAX_RESULTS) break; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (discovered.length >= MAX_RESULTS) break; + if (!entry.isFile() || !entry.name.endsWith('.vync')) continue; + const real = await fs + .realpath(path.join(dir, entry.name)) + .catch(() => null); + if (real && !registered.has(real)) { + discovered.push(real); + } + } + } catch { + /* directory doesn't exist or not readable */ + } + } + res.json({ files: [...new Set(discovered)] }); + } catch (err: any) { + console.error('[vync] Discovery error:', err); + res.status(500).json({ error: 'Discovery failed' }); + } + }); + // --- Sync API (file-scoped) --- app.get('/api/sync', async (req, res) => { const filePath = req.query.file as string; @@ -180,7 +218,7 @@ export async function startServer( if (mode === 'production' && options.staticDir) { app.use(express.static(options.staticDir)); - app.get('*', (_req, res) => { + app.get('*path', (_req, res) => { res.sendFile(path.join(options.staticDir!, 'index.html')); }); } else if (mode === 'development') {