입력창 위에 프로젝트 · 브랜치 · 변경 · 동기화 · CI 상태를 한 줄로 보여 준다

개발자를 위한 커뮤니케이션 허브. 대화 · 파일 · 이슈 · 저장소 · AI 를 한 화면에 모은다.
일반 메신저는 개발 맥락을 모르고, 개발 도구는 대화를 담지 못한다. Nexus 는 그 사이를 메운다.
| 영역 | 내용 |
|---|---|
| 대화 | 스페이스 · 카테고리 · 채널(공개/비공개) · 실시간 전송 · 스레드 · 답장(인용) · 멘션 · 리액션 · 핀 · 마크다운 |
| DM · 프레즌스 | 스페이스 안 1:1 DM · 온라인 / 자리비움(10분 무입력) / 오프라인 · 입력 중 표시 |
| 멤버 · 권한 | 스페이스 만들기 · 초대 코드 · 초대 링크 · 역할(owner · admin · member · guest) · 비공개 채널 명단 · 역할별 채널 권한(가리기 · 읽기 전용) |
| 알림 | 알림함(멘션 · @channel · DM · 내 글의 답글) · 종류별 스위치 · 채널 음소거 · 앱을 보고 있지 않을 때 OS 알림(Windows 토스트 · 브라우저) · Windows 트레이 |
| 파일 | 첨부 업로드(진행률) · 이미지 미리보기 · 스페이스 파일 목록 · 무기한 보관 |
| 이슈 | 칸반 보드(끌어 옮기기) · 상세 · 댓글 · 라벨 · 대화 → 이슈 · 스프린트 · 번다운(스페이스마다 켜는 선택 기능) |
| GitHub | 계정 연결(OAuth) · 웹훅 자동 등록 · 브랜치 · 파일 트리 · 커밋 · PR 열람 |
| 인덱싱 | 저장소를 청크로 나눠 임베딩 · 벡터 검색(pgvector HNSW) · push 마다 증분 갱신 |
| AI 패널 | 자유 지시문 + 프리셋(요약 · 이슈 초안) · 컨텍스트(메시지 · 채널 최근 대화 · 저장소 RAG) · 이어 묻기 · 지난 대화 다시 열기 |
| 설정 | 표시 이름 · 프로필 사진 · 비밀번호 변경 · 알림 · 테마(시스템 · 라이트 · 다크) |
| 화면 | 자체 UI — Material · Cupertino 없이 직접 만든 부품(app/lib/ui/) · 반응형(데스크톱 · 태블릿 · 모바일) · 새로고침 · 로그인 뒤 원래 주소로 |
app/ Flutter — 한 코드베이스로 Windows · Android · Web (iOS 는 macOS 가 없어 동결)
│ Riverpod · go_router · dio · drift(오프라인 캐시 + 전송 큐) · 자체 UI(WidgetsApp)
│
│ REST + Socket.IO
▼
server/ NestJS + Prisma
├─ PostgreSQL + pgvector 모든 데이터 · 코드 임베딩 · 작업 큐(AI · 인덱싱)
├─ 스토리지 첨부 파일 (개발은 로컬 디스크, 배포는 S3 호환 — R2)
├─ LLM gemini · local(Ollama) · fake
└─ 임베딩 gemini · local(Ollama) · fake
deploy/ VM 한 대 — Docker Compose(postgres · server · nginx · cloudflared · backup)
웹과 API 를 nginx 한 오리진으로 낸다 · Cloudflare Tunnel · R2
Space 가 모든 데이터의 루트인 멀티테넌트 구조다. 채널 · 메시지 · 이슈 · 저장소는 전부 스페이스에 속하고, 스페이스에 속한 테이블은 spaceId 를 직접 가진다. 볼 수 없는 리소스는 403 이 아니라 404 로 답한다.
외부 원본(GitHub)은 사본을 두지 않고 프록시한다. Redis 는 쓰지 않는다 — 큐도 Postgres 에 둔다.
| 폴더 | 설명 |
|---|---|
server/ | NestJS 백엔드 — REST API · Socket.IO 게이트웨이 · 계약 검증 스크립트(scripts/) |
app/ | Flutter 앱 — 실행법은 app/README.md |
deploy/ | 배포 구성 — prod compose · nginx · 절차는 deploy/README.md |
design-system/ | 디자인 토큰(tokens.css) · 컴포넌트 · 화면 프리뷰 |
docs/ | 기획 · 설계 문서 · 진행 기록 |
필요한 것: Node.js 22 · Flutter 3.44.9 이상 · WSL2(Ubuntu) 또는 Docker
git clone https://github.com/dbsrjs/Nexus.git && cd Nexus
npm --prefix server install
npm run env:setup # server/.env 생성 · 시크릿 자동 채움
npm run db:setup # (1회) WSL 안에 Postgres + pgvector
npm run db:up # Docker 라면 위 둘 대신 npm run db:up:docker
npm --prefix server run prisma:generate
npm --prefix server run prisma:deploy
npm run db:seed
npm run server:dev # http://localhost:3000/api
env:setup 은 여러 번 돌려도 안전하다. 만들어 낼 수 없는 값(GITHUB_CLIENT_ID · GITHUB_CLIENT_SECRET · PUBLIC_BASE_URL)은 끝에 목록으로 알려 준다 — GitHub 연동을 쓸 때만 필요하다. AI · 인덱싱은 .env 의 LLM_PROVIDER · EMBEDDING_PROVIDER 를 채워야 켜진다.
Windows 에서는
DATABASE_URL에localhost대신127.0.0.1을 쓴다(WSL 포워딩이 IPv4 만 동작한다). 서버가listen EACCES ...:3000으로 죽으면 Windows 가 그 포트를 예약한 것이다 — 처방은 CLAUDE.md §2.
cd app
flutter pub get
flutter run -d windows --dart-define=API_BASE=http://127.0.0.1:3000
flutter run -d chrome --web-port=5173 # 서버 CORS 가 5173 만 허용한다
Android 에뮬레이터는 --dart-define=API_BASE=http://10.0.2.2:3000 을 넘긴다(에뮬레이터에게 127.0.0.1 은 자기 자신이다). Windows 데스크톱 빌드에는 개발자 모드가 켜져 있어야 한다.
| 명령 | 내용 | |
|---|---|---|
| 명령 | 내용 | 규모 (2026-10-09) |
| --- | --- | --- |
npm run server:test · server:lint | 서버 단위 테스트(Jest) · ESLint — 순수 로직 · 가드 · 권한 규칙 | 535개 |
npm run check:* | 실서버 · 실DB · 실소켓 계약 검증 — 실시간 · 리액션 · 스레드 · 첨부 · 이슈 · GitHub 연동 · 인덱싱 · AI · 설정 · 멤버 · 권한 · DM · 프레즌스 · 알림 · 테넌트 격리(check:tenancy — 스페이스 경로 전부를 남의 id 로 친다). GitHub 은 스스로 띄우는 가짜 서버로 대신한다 | 20종 1,123개 |
npm run check:migrations · check:sql-time | 마이그레이션 · raw SQL 정적 검사 (DB 불필요) | |
cd app && flutter analyze && flutter test | 앱 정적 분석 · 단위 · 위젯 테스트 | 591개 |
npm run app:flow | 앱 통합 테스트 — Windows 앱을 실서버에 붙여 로그인부터 전송 · 실시간 · 설정 · 멤버 · DM · 알림함 · AI 까지 돈다 | 약 30초 |
npm run app:flow:headless | 같은 흐름을 창 없이(flutter_tester) 돈다 — CI 가 이것을 돈다 | 약 15초 |
CI(.github/workflows/ci.yml)가 main 과 feat/** 의 push 마다 위 전부를 돈다 — 앱 통합 테스트는 창 없는 쪽(app:flow:headless)으로, 첨부는 S3 경로(SeaweedFS)로도 한 번 더. Windows 창으로 보는 app:flow 는 화면 모습을 바꿨을 때 사람이 돌린다.
1~19단계와 «마지막»(출시 준비)의 네 갈래가 끝났다(2026-10-09). 위 기능표가 전부 동작하고, 배포 구성(deploy/) · 테넌트 격리 통합 검증 · 딥링크 · 데스크톱 · 웹 알림 + Windows 트레이까지 들어갔다.
| 남은 것 | 내용 |
|---|---|
| 실제 VM 배포 | 구성과 절차(deploy/README.md)는 있다. VM · R2 · Cloudflare Tunnel 을 정하고 올리는 일 |
| OS 수준 링크 연결 | Android App Links · Windows 프로토콜 등록 — 공개 도메인이 정해진 뒤 |
| 20 GitLab 연동 | provider 추상화 뒤에 GitLab. 배포 뒤로 미뤄도 되는 유일한 단계 |
모바일 푸시(FCM)는 범위에서 뺐다(2026-10-09). 단계마다의 결정과 확인 내역은 진행 기록 에 있다.
| 로드맵 | 목표 |
|---|---|
| Phase 0 | 나 혼자 쓰는 개발 허브 — 프로젝트를 채널로 나누고 할 일 · 저장소를 붙인다 |
| Phase 1 | 2~10인 소규모 팀 — 초대 · 온보딩 · 알림 |
| Phase 2 | 공개 서비스 — 테넌트 격리 · 스토리지 쿼터 · 과금 |
| 문서 | 내용 |
|---|---|
| 코드 둘러보기 | 처음 열었을 때 여기부터. 돌려 보기 · 구조 · 한 줄기 따라가기 |
| 제품 기획 | 방향 · 타겟 · 기능 범위 · 로드맵 |
| 백엔드 설계 | 멀티테넌시 · 데이터 모델 · API 계약 · 실시간 · 인증 |
| 앱 설계 | Flutter 스택 · 화면 · 상태 관리 · 오프라인 전략 |
| 인프라 설계 | 배포 구성 · 공개 저장소 보안 체크리스트 |
| 디자인 시스템 | 색 · 타이포 · 간격 · 컴포넌트 |
| 전환 계획 | 작업 목록과 진행 상황 |
| 진행 기록 | 단계마다 갈린 결정 · 확인한 것 · 확인하지 못한 것 |
| 기술 스택 가이드 | 스택별 학습 순서 · 코드 읽기 시작점 |
| 서버 README | 서버 셋업 · 규약 · Ollama · 실제 GitHub 웹훅 붙이는 법 |
| 배포 README | VM · R2 · Cloudflare Tunnel 로 올리는 절차 |
| 단계별 설계 스펙 | 단계마다 정한 것 · 범위에서 뺀 것과 그 이유 |
hooks/register.tsx 204 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CiSnapshot, GitSnapshot } from '../types'
5import { ago, ciLabel, parseRuns, parseStatus } from './parse'
6
7const git = atom({ plugin: 'git-band', key: 'git' } as const, null)
8const ci = atom({ plugin: 'git-band', key: 'ci' } as const, null)
9
10// git 은 싸서 자주, CI(gh → GitHub API)는 비싸서 드물게 본다.
11// 실행 중인 CI 만은 결과가 곧 바뀌므로 git 주기에 맞춰 따라간다.
12const TICK_MS = 15_000
13const CI_EVERY_TICKS = 6
14
15// 진행 중인 작업 표시. 파일 이름은 git 이 쓰는 그대로다.
16const OPERATIONS: readonly [string, string][] = [
17 ['rebase-merge', '리베이스 중'],
18 ['rebase-apply', '리베이스 중'],
19 ['MERGE_HEAD', '병합 중'],
20 ['CHERRY_PICK_HEAD', '체리픽 중'],
21 ['REVERT_HEAD', '되돌리기 중'],
22 ['BISECT_LOG', 'bisect 중'],
23]
24
25// 모듈 변수는 리로드 때 초기화돼도 괜찮은 것만 둔다(겹침 방지 · 주기 계산).
26// $ 를 받는 함수는 검증기가 따라갈 수 있게 최상위 함수 선언으로 둔다.
27let busy = false
28let tick = 0
29let lastCiKey = ''
30
31async function run($: EngineInterface, argv: string[], cwd?: string) {
32 try {
33 return await $.process.run(argv, { cwd, timeoutMs: 20_000 })
34 } catch {
35 // 명령이 없거나(ENOENT) 시간 초과 — 호출부가 「모름」으로 접는다.
36 return null
37 }
38}
39
40async function readGit($: EngineInterface): Promise<GitSnapshot | null> {
41 const top = await run($, ['git', 'rev-parse', '--show-toplevel', '--absolute-git-dir'])
42 if (!top || top.exitCode !== 0) return null
43 const [root, gitDir] = top.stdout.trim().split(/\r?\n/)
44 if (!root || !gitDir) return null
45
46 const [status, log] = await Promise.all([
47 run($, ['git', 'status', '--porcelain=v2', '--branch'], root),
48 // 날짜는 %cr(로케일 영어) 대신 ISO 로 받아 한국어로 직접 쓴다.
49 run($, ['git', 'log', '-1', '--format=%h%x1f%s%x1f%cI'], root),
50 ])
51 if (!status || status.exitCode !== 0) return null
52
53 let operation: string | null = null
54 for (const [file, label] of OPERATIONS) {
55 try {
56 await $.fs.stat(`${gitDir}/${file}`)
57 operation = label
58 break
59 } catch {
60 // 없으면 그 작업이 아니다.
61 }
62 }
63
64 let lastCommit: GitSnapshot['lastCommit'] = null
65 if (log && log.exitCode === 0 && log.stdout.trim()) {
66 const [hash, subject, iso] = log.stdout.trim().split('\x1f')
67 lastCommit = { hash: hash ?? '', subject: subject ?? '', age: iso ?? '' }
68 }
69
70 const name = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? root
71 return { project: name, operation, lastCommit, ...parseStatus(status.stdout) }
72}
73
74async function readCi($: EngineInterface, snap: GitSnapshot): Promise<CiSnapshot> {
75 if (snap.isDetached) return { kind: 'unknown', reason: '분리된 HEAD' }
76 const r = await run($, [
77 'gh', 'run', 'list',
78 '--branch', snap.branch,
79 '--limit', '1',
80 '--json', 'status,conclusion,workflowName,createdAt,headSha',
81 ])
82 if (!r) return { kind: 'unknown', reason: 'gh 없음' }
83 if (r.exitCode !== 0) {
84 const msg = r.stderr.toLowerCase()
85 if (msg.includes('auth') || msg.includes('login')) return { kind: 'unknown', reason: 'gh 로그인 필요' }
86 if (msg.includes('could not resolve') || msg.includes('no git remote')) return { kind: 'unknown', reason: 'GitHub 원격 없음' }
87 return { kind: 'unknown', reason: 'gh 실패' }
88 }
89 return parseRuns(r.stdout)
90}
91
92async function refresh($: EngineInterface, forceCi: boolean) {
93 if (busy) return
94 busy = true
95 try {
96 const snap = await readGit($)
97 await update($, git, () => snap)
98 if (!snap) return
99
100 const prev = await read($, ci)
101 const ciKey = `${snap.project}|${snap.branch}|${snap.oid}`
102 const isRunning = prev?.kind === 'run' && prev.status !== 'completed'
103 // 브랜치나 HEAD 가 바뀌면(커밋 · 체크아웃 · push 후) 곧바로 다시 본다.
104 if (forceCi || isRunning || ciKey !== lastCiKey || tick % CI_EVERY_TICKS === 0) {
105 lastCiKey = ciKey
106 const next = await readCi($, snap)
107 await update($, ci, () => next)
108 }
109 } finally {
110 busy = false
111 }
112}
113
114export const register: Register = on => {
115 on('session.start', async ($, e, next) => {
116 const r = await next(e)
117 void refresh($, true)
118 $.clock.every(TICK_MS, () => {
119 tick++
120 void refresh($, false)
121 })
122 await $.command.register({
123 name: 'git-band',
124 description: 'git 상태 띠를 지금 새로 고친다(CI 포함)',
125 })
126 return r
127 })
128
129 on('command.run', { command: 'git-band' }, async $ => {
130 await refresh($, true)
131 return { text: 'git 상태를 새로 고쳤습니다.' }
132 })
133
134 // 턴이 끝나면 Claude 가 커밋 · 체크아웃 · push 했을 수 있으니 바로 반영한다.
135 on('turn.complete', async ($, e, next) => {
136 const r = await next(e)
137 void refresh($, false)
138 return r
139 })
140
141 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
142 if (e.props.hasSurvey) return next(e)
143 const snap = await read($, git)
144 if (!snap) return next(e)
145 const c = await read($, ci)
146 const { Box, Text } = $.ui.resolve(e)
147 const now = await $.clock.now()
148
149 const sync =
150 snap.upstream === null
151 ? '원격 추적 없음'
152 : snap.ahead === 0 && snap.behind === 0
153 ? '동기화됨'
154 : `↑${snap.ahead} ↓${snap.behind}`
155 const changes = [
156 snap.conflicted ? `충돌 ${snap.conflicted}` : '',
157 snap.staged ? `스테이징 ${snap.staged}` : '',
158 snap.unstaged ? `수정 ${snap.unstaged}` : '',
159 snap.untracked ? `새 파일 ${snap.untracked}` : '',
160 ].filter(Boolean)
161 const changeText = changes.length ? changes.join(' · ') : '깨끗함'
162 const changeColor = snap.conflicted ? 'error' : changes.length ? 'warning' : 'success'
163
164 let ciText: { text: string; color: string } | null = null
165 let ciMeta = ''
166 if (c) {
167 ciText = ciLabel(c)
168 if (c.kind === 'run') {
169 const when = ago(c.createdAt, now)
170 // 최신 실행이 지금 HEAD 의 것이 아니면 「아직 push 안 함 / CI 안 돎」을 알린다.
171 const stale = c.headSha && snap.oid && c.headSha !== snap.oid ? ' · 이전 커밋 기준' : ''
172 ciMeta = ` ${c.workflow}${when ? ` · ${when}` : ''}${stale}`
173 }
174 }
175
176 return (
177 <Box flexDirection="column" width={e.props.bodyColumns}>
178 <Box flexDirection="row">
179 <Text wrap="truncate-end">
180 <Text bold color="claude">{snap.project}</Text>
181 <Text dimColor> ⎇ </Text>
182 <Text color="suggestion">{snap.branch}</Text>
183 {snap.isDetached ? <Text color="warning"> (분리된 HEAD)</Text> : null}
184 {snap.operation ? <Text color="error"> [{snap.operation}]</Text> : null}
185 <Text dimColor> │ </Text>
186 <Text>{sync}</Text>
187 <Text dimColor> │ </Text>
188 <Text color={changeColor}>{changeText}</Text>
189 {ciText ? <Text dimColor> │ </Text> : null}
190 {ciText ? <Text color={ciText.color}>{ciText.text}</Text> : null}
191 {ciMeta ? <Text dimColor>{ciMeta}</Text> : null}
192 </Text>
193 </Box>
194 {snap.lastCommit ? (
195 <Text dimColor wrap="truncate-end">
196 {snap.lastCommit.hash} {snap.lastCommit.subject}
197 {ago(snap.lastCommit.age, now) ? ` · ${ago(snap.lastCommit.age, now)}` : ''}
198 </Text>
199 ) : null}
200 </Box>
201 )
202 })
203}
204hooks/parse.ts 104 lines1import type { CiSnapshot, GitSnapshot } from '../types'
2
3// git status --porcelain=v2 --branch 를 파싱한다. 사람이 읽는 형식(git status)은
4// 로케일 · 버전마다 문구가 바뀌어 기계용 v2 형식을 쓴다.
5export function parseStatus(
6 text: string,
7): Omit<GitSnapshot, 'project' | 'operation' | 'lastCommit'> {
8 let oid = ''
9 let head = ''
10 let upstream: string | null = null
11 let ahead = 0
12 let behind = 0
13 let staged = 0
14 let unstaged = 0
15 let untracked = 0
16 let conflicted = 0
17
18 for (const line of text.split(/\r?\n/)) {
19 if (line.startsWith('# branch.oid ')) oid = line.slice(13)
20 else if (line.startsWith('# branch.head ')) head = line.slice(14)
21 else if (line.startsWith('# branch.upstream ')) upstream = line.slice(18)
22 else if (line.startsWith('# branch.ab ')) {
23 const m = /\+(\d+) -(\d+)/.exec(line)
24 if (m) {
25 ahead = Number(m[1])
26 behind = Number(m[2])
27 }
28 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
29 // XY: X 는 인덱스(스테이징), Y 는 작업 트리. '.' 은 변경 없음.
30 const xy = line.slice(2, 4)
31 if (xy[0] !== '.') staged++
32 if (xy[1] !== '.') unstaged++
33 } else if (line.startsWith('u ')) conflicted++
34 else if (line.startsWith('? ')) untracked++
35 }
36
37 const isDetached = head === '(detached)'
38 return {
39 oid,
40 branch: isDetached ? oid.slice(0, 7) : head,
41 isDetached,
42 upstream,
43 ahead,
44 behind,
45 staged,
46 unstaged,
47 untracked,
48 conflicted,
49 }
50}
51
52// gh run list --json 결과 중 첫 줄. 실패를 'unknown' 으로 접어 띠가 이유를 보이게 한다.
53export function parseRuns(stdout: string): CiSnapshot {
54 let runs: unknown
55 try {
56 runs = JSON.parse(stdout)
57 } catch {
58 return { kind: 'unknown', reason: 'gh 응답 해석 실패' }
59 }
60 if (!Array.isArray(runs) || runs.length === 0) return { kind: 'none' }
61 const r = runs[0] as Record<string, unknown>
62 return {
63 kind: 'run',
64 workflow: String(r.workflowName ?? ''),
65 status: String(r.status ?? ''),
66 conclusion: String(r.conclusion ?? ''),
67 createdAt: String(r.createdAt ?? ''),
68 headSha: String(r.headSha ?? ''),
69 }
70}
71
72// 「3분 전」 같은 상대 시각. 모르는 값이면 빈 문자열(화면이 말하지 않는다).
73export function ago(iso: string, nowMs: number): string {
74 const t = Date.parse(iso)
75 if (Number.isNaN(t)) return ''
76 const s = Math.max(0, Math.round((nowMs - t) / 1000))
77 if (s < 60) return '방금'
78 if (s < 3600) return `${Math.floor(s / 60)}분 전`
79 if (s < 86400) return `${Math.floor(s / 3600)}시간 전`
80 return `${Math.floor(s / 86400)}일 전`
81}
82
83export function ciLabel(ci: CiSnapshot): { text: string; color: string } {
84 if (ci.kind === 'unknown') return { text: `CI ? (${ci.reason})`, color: 'inactive' }
85 if (ci.kind === 'none') return { text: 'CI 실행 없음', color: 'inactive' }
86 if (ci.status !== 'completed') {
87 return { text: ci.status === 'queued' ? 'CI ⏳ 대기' : 'CI ⟳ 실행 중', color: 'warning' }
88 }
89 switch (ci.conclusion) {
90 case 'success':
91 return { text: 'CI ✓ 통과', color: 'success' }
92 case 'failure':
93 case 'timed_out':
94 case 'startup_failure':
95 return { text: 'CI ✗ 실패', color: 'error' }
96 case 'cancelled':
97 return { text: 'CI ⊘ 취소', color: 'inactive' }
98 case 'skipped':
99 return { text: 'CI – 건너뜀', color: 'inactive' }
100 default:
101 return { text: `CI ${ci.conclusion}`, color: 'inactive' }
102 }
103}
104types/index.d.ts 36 lines1// 띠가 그리는 git 상태. 저장소가 아니면 null.
2export type GitSnapshot = {
3 project: string
4 oid: string
5 branch: string
6 isDetached: boolean
7 upstream: string | null
8 ahead: number
9 behind: number
10 staged: number
11 unstaged: number
12 untracked: number
13 conflicted: number
14 operation: string | null
15 lastCommit: { hash: string; subject: string; age: string } | null
16}
17
18// gh 로 읽은 최신 워크플로 실행. gh 가 없거나 인증이 안 되면 kind 가 'unknown'.
19export type CiSnapshot =
20 | { kind: 'unknown'; reason: string }
21 | { kind: 'none' }
22 | {
23 kind: 'run'
24 workflow: string
25 status: string
26 conclusion: string
27 createdAt: string
28 headSha: string
29 }
30
31declare module 'claude-code' {
32 interface PluginState {
33 'git-band': { git: GitSnapshot | null; ci: CiSnapshot | null }
34 }
35}
36