@reopt-ai/dev-proxy 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +371 -0
- package/README_KO.md +371 -0
- package/bin/dev-proxy.js +3 -0
- package/dist/bootstrap.js +3 -0
- package/dist/cli/config-io.js +110 -0
- package/dist/cli/output.js +37 -0
- package/dist/cli.js +78 -0
- package/dist/commands/config.js +60 -0
- package/dist/commands/doctor.js +334 -0
- package/dist/commands/help.js +7 -0
- package/dist/commands/init.js +199 -0
- package/dist/commands/project.js +69 -0
- package/dist/commands/status.js +30 -0
- package/dist/commands/version.js +10 -0
- package/dist/commands/worktree.js +292 -0
- package/dist/components/app.js +394 -0
- package/dist/components/detail-panel.js +122 -0
- package/dist/components/footer-bar.js +62 -0
- package/dist/components/request-list.js +104 -0
- package/dist/components/splash.js +32 -0
- package/dist/components/status-bar.js +19 -0
- package/dist/hooks/use-mouse.js +66 -0
- package/dist/index.js +153 -0
- package/dist/proxy/certs.js +68 -0
- package/dist/proxy/config.js +78 -0
- package/dist/proxy/routes.js +70 -0
- package/dist/proxy/server.js +403 -0
- package/dist/proxy/types.js +1 -0
- package/dist/proxy/worktrees.js +116 -0
- package/dist/store.js +567 -0
- package/dist/utils/format.js +121 -0
- package/dist/utils/list-layout.js +48 -0
- package/package.json +83 -0
package/README_KO.md
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# dev-proxy
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@reopt-ai/dev-proxy)
|
|
4
|
+
[](https://github.com/reopt-ai/dev-proxy/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
|
|
8
|
+
**서브도메인 기반 리버스 프록시 + 실시간 HTTP/WS 트래픽 인스펙터 TUI.**
|
|
9
|
+
|
|
10
|
+
수십 개의 서비스, 워크트리, AI 에이전트 코딩 세션이 동시에 돌아가는 에이전틱 개발 환경을 위해 만들었습니다 — 하나의 프록시로 전부 라우팅하고, 하나의 터미널로 전부 봅니다.
|
|
11
|
+
|
|
12
|
+
`*.{domain}:3000`으로 들어오는 요청을 서브도메인별로 로컬 서비스에 라우팅하고, 모든 트래픽을 터미널 대시보드로 실시간 모니터링합니다. Charles나 Proxyman의 가벼운 터미널 버전이라고 생각하면 됩니다 — 로컬 멀티서비스 개발에 특화되어 있습니다.
|
|
13
|
+
|
|
14
|
+
[English Documentation](README.md)
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
## 왜 dev-proxy인가?
|
|
19
|
+
|
|
20
|
+
프론트엔드, API, 인증, 문서, 어드민 등 여러 로컬 서비스를 동시에 개발할 때 서브도메인 라우팅과 트래픽 모니터링이 필요합니다. 기존 도구들은 너무 무겁거나(nginx, Caddy) GUI 전용(Charles, Proxyman)입니다.
|
|
21
|
+
|
|
22
|
+
dev-proxy는:
|
|
23
|
+
|
|
24
|
+
- **제로 설정 시작** — `localhost` 기본값과 TLS 인증서 자동 생성으로 바로 실행
|
|
25
|
+
- **터미널 네이티브** — 브라우저 창 없이 터미널에서 바로 확인
|
|
26
|
+
- **Vim 스타일 네비게이션** — `j`/`k`로 탐색, `/`로 검색, `r`로 재전송
|
|
27
|
+
- **Worktree 지원** — `branch--app.domain` 형태로 워크트리별 포트 자동 라우팅
|
|
28
|
+
- **경량** — 런타임 의존성 2개(`ink` + `react`), ~10fps 스로틀 렌더링
|
|
29
|
+
|
|
30
|
+
## 주요 기능
|
|
31
|
+
|
|
32
|
+
- HTTP 요청/응답 실시간 모니터링 (메서드, 상태, 크기, 지연시간)
|
|
33
|
+
- WebSocket 연결 추적 (OPEN / CLOSED / ERROR)
|
|
34
|
+
- Request/Response 헤더, 쿠키, 쿼리 파라미터 인스펙션
|
|
35
|
+
- 노이즈 필터 (`_next/`, `favicon`), 에러 전용 모드, URL/메서드 검색
|
|
36
|
+
- 원본 헤더 포함 요청 재전송 및 curl 클립보드 복사
|
|
37
|
+
- 업스트림 `http`/`https`, `ws`/`wss` 타깃 지원
|
|
38
|
+
- 프로젝트 설정 기반 Git worktree 동적 라우팅
|
|
39
|
+
- [mkcert](https://github.com/FiloSottile/mkcert)를 이용한 TLS 인증서 자동 생성
|
|
40
|
+
- 프로젝트 기반 설정: 전역 (`~/.dev-proxy/config.json`) + 프로젝트별 (`.dev-proxy.json`)
|
|
41
|
+
|
|
42
|
+
## 요구 사항
|
|
43
|
+
|
|
44
|
+
- **Node.js** >= 20.11
|
|
45
|
+
- **mkcert** _(선택, HTTPS용)_ — `brew install mkcert && mkcert -install`
|
|
46
|
+
|
|
47
|
+
## 빠른 시작
|
|
48
|
+
|
|
49
|
+
### 사람용
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npm install -g @reopt-ai/dev-proxy
|
|
53
|
+
dev-proxy init
|
|
54
|
+
dev-proxy
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Enter**를 눌러 인스펙터를 활성화한 뒤, 브라우저에서 `http://www.example.dev:3000`을 열어보세요.
|
|
58
|
+
|
|
59
|
+
### LLM 에이전트용
|
|
60
|
+
|
|
61
|
+
AI 코딩 에이전트(Claude Code, Cursor, Copilot 등)에 이 프롬프트를 붙여넣으세요:
|
|
62
|
+
|
|
63
|
+
> Install and configure dev-proxy by following the instructions here:
|
|
64
|
+
> https://raw.githubusercontent.com/reopt-ai/dev-proxy/main/docs/guide/installation.md
|
|
65
|
+
|
|
66
|
+
## 설치
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# npx (설치 없이)
|
|
70
|
+
npx @reopt-ai/dev-proxy
|
|
71
|
+
|
|
72
|
+
# 전역 설치
|
|
73
|
+
npm install -g @reopt-ai/dev-proxy
|
|
74
|
+
|
|
75
|
+
# 소스에서 실행
|
|
76
|
+
git clone https://github.com/reopt-ai/dev-proxy.git
|
|
77
|
+
cd dev-proxy && pnpm install && pnpm proxy
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 설정
|
|
81
|
+
|
|
82
|
+
설정은 두 단계로 구성됩니다:
|
|
83
|
+
|
|
84
|
+
1. **`~/.dev-proxy/config.json`** — 전역 설정 (도메인, 포트, TLS, 프로젝트 목록)
|
|
85
|
+
2. **`.dev-proxy.json`** — 프로젝트별 설정 (라우트, 워크트리)
|
|
86
|
+
|
|
87
|
+
### 전역 설정 (`~/.dev-proxy/config.json`)
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"domain": "example.dev",
|
|
92
|
+
"port": 3000,
|
|
93
|
+
"httpsPort": 3443,
|
|
94
|
+
"projects": ["/path/to/your/project"]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 프로젝트 설정 (`.dev-proxy.json`)
|
|
99
|
+
|
|
100
|
+
`projects`에 등록된 각 프로젝트 루트에 `.dev-proxy.json`을 배치합니다. 라우트와 워크트리를 여기에 정의합니다.
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"routes": {
|
|
105
|
+
"www": "http://localhost:3001",
|
|
106
|
+
"studio": "http://localhost:3001",
|
|
107
|
+
"api": "http://localhost:4000",
|
|
108
|
+
"*": "http://localhost:3001"
|
|
109
|
+
},
|
|
110
|
+
"worktrees": {
|
|
111
|
+
"feature-auth": { "port": 4001 }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- `"*"`는 와일드카드 — 매칭되지 않는 서브도메인이 이 타깃으로 라우팅됩니다
|
|
117
|
+
- 여러 프로젝트가 같은 서브도메인을 등록하면 먼저 등록된 것이 우선
|
|
118
|
+
- `certPath`/`keyPath`는 전역 설정에서 지정하며, `~/.dev-proxy/` 기준 상대 경로로 해석됩니다
|
|
119
|
+
|
|
120
|
+
### HTTPS
|
|
121
|
+
|
|
122
|
+
인증서는 `~/.dev-proxy/certs/`에 저장됩니다. 인증서가 없으면 [mkcert](https://github.com/FiloSottile/mkcert)를 사용해 자동 생성합니다.
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
brew install mkcert
|
|
126
|
+
mkcert -install
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
mkcert가 설치되어 있으면 첫 실행 시 와일드카드 인증서를 자동 생성합니다. 수동 작업 불필요.
|
|
130
|
+
|
|
131
|
+
### Worktree 라우팅
|
|
132
|
+
|
|
133
|
+
dev-proxy는 Git worktree 기반 동적 라우팅을 지원합니다. 호스트명에 `branch--app.domain` 형태를 사용하면 워크트리별 포트로 라우팅됩니다.
|
|
134
|
+
|
|
135
|
+
**자동 라이프사이클 관리:**
|
|
136
|
+
|
|
137
|
+
프로젝트의 `.dev-proxy.json`에 `worktreeConfig`를 추가합니다. `services`로 서브도메인별 포트 매핑을 정의하면 dev-proxy가 포트를 자동 할당하고 `.env.local`을 생성하여 dev 서버가 어떤 포트에서 listen할지 알 수 있습니다:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"routes": {
|
|
142
|
+
"www": "http://localhost:3001",
|
|
143
|
+
"data": "http://localhost:4001",
|
|
144
|
+
"*": "http://localhost:3001"
|
|
145
|
+
},
|
|
146
|
+
"worktrees": {
|
|
147
|
+
"main": { "ports": { "www": 3001, "data": 4001 } }
|
|
148
|
+
},
|
|
149
|
+
"worktreeConfig": {
|
|
150
|
+
"portRange": [4101, 5000],
|
|
151
|
+
"directory": "../myproject-{branch}",
|
|
152
|
+
"services": {
|
|
153
|
+
"www": { "env": "PORT" },
|
|
154
|
+
"data": { "env": "DATA_PORT" }
|
|
155
|
+
},
|
|
156
|
+
"envFile": ".env.local",
|
|
157
|
+
"hooks": {
|
|
158
|
+
"post-create": "pnpm install",
|
|
159
|
+
"post-remove": "echo cleanup done"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
한 줄로 워크트리를 생성/제거합니다:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
dev-proxy worktree create feature-auth
|
|
169
|
+
# → git worktree add
|
|
170
|
+
# → 포트 할당: www=4101, data=4102
|
|
171
|
+
# → .env.local 생성: PORT=4101, DATA_PORT=4102
|
|
172
|
+
# → post-create 훅 실행 (pnpm install)
|
|
173
|
+
|
|
174
|
+
dev-proxy worktree destroy feature-auth
|
|
175
|
+
# → post-remove 훅 실행, git worktree remove, 포트 해제
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**라우팅 방식:**
|
|
179
|
+
|
|
180
|
+
- `feature-auth--www.example.dev:3000` → 포트 4101 (www 서비스)로 라우팅
|
|
181
|
+
- `feature-auth--data.example.dev:3000` → 포트 4102 (data 서비스)로 라우팅
|
|
182
|
+
- 설정 파일 실시간 감시 — 변경 즉시 라우팅 업데이트
|
|
183
|
+
- 미등록 워크트리는 silent fallback 없이 오프라인 에러 페이지 표시
|
|
184
|
+
|
|
185
|
+
**수동 모드** (`worktreeConfig` 없이):
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
dev-proxy worktree add feature-auth 4001 # 단일 포트 등록 (git 조작 없음)
|
|
189
|
+
dev-proxy worktree remove feature-auth # 해제만
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## 실행
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
# 전역 설치 또는 npx로
|
|
196
|
+
dev-proxy
|
|
197
|
+
|
|
198
|
+
# 소스에서
|
|
199
|
+
pnpm proxy
|
|
200
|
+
|
|
201
|
+
# 디버그 모드 (tsx, 빌드 생략)
|
|
202
|
+
pnpm proxy:src
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`pnpm proxy`는 `dist/`를 빌드한 뒤 `NODE_ENV=production`으로 실행합니다. Ink/React dev-mode 메모리 누수를 방지하기 위한 기본 동작입니다.
|
|
206
|
+
|
|
207
|
+
### UI 상태
|
|
208
|
+
|
|
209
|
+
TUI는 세 가지 상태를 가집니다:
|
|
210
|
+
|
|
211
|
+
1. **Splash** — 설정된 라우트와 리스닝 포트 표시. **Enter**를 눌러 활성화.
|
|
212
|
+
2. **Inspect** — 실시간 트래픽 대시보드 (목록 + 상세 패널).
|
|
213
|
+
3. **Standby** — 60초 미조작 시 자동 슬립하여 메모리 부담 감소. **I** 또는 **Enter**로 복귀.
|
|
214
|
+
|
|
215
|
+
## 키바인딩
|
|
216
|
+
|
|
217
|
+
### 네비게이션
|
|
218
|
+
|
|
219
|
+
| 키 | 동작 |
|
|
220
|
+
| --------- | ------------------------------ |
|
|
221
|
+
| `←` / `→` | 리스트 / 상세 패널 포커스 전환 |
|
|
222
|
+
| `j` / `↓` | 다음 요청 |
|
|
223
|
+
| `k` / `↑` | 이전 요청 |
|
|
224
|
+
| `g` | 첫 요청으로 이동 |
|
|
225
|
+
| `G` | 마지막 요청으로 이동 |
|
|
226
|
+
| `Enter` | 상세 패널 열기 |
|
|
227
|
+
| `Esc` | 리스트로 복귀 / 검색 초기화 |
|
|
228
|
+
|
|
229
|
+
### 상세 패널
|
|
230
|
+
|
|
231
|
+
| 키 | 동작 |
|
|
232
|
+
| --------- | ---------------- |
|
|
233
|
+
| `↑` / `↓` | 상세 내용 스크롤 |
|
|
234
|
+
|
|
235
|
+
> 상세 패널에 포커스하면 Follow 모드가 자동 해제되어 새 요청이 들어와도 선택이 유지됩니다.
|
|
236
|
+
|
|
237
|
+
### 필터 & 기능
|
|
238
|
+
|
|
239
|
+
| 키 | 동작 |
|
|
240
|
+
| --- | ----------------------------------------- |
|
|
241
|
+
| `/` | 검색 모드 (URL, 메서드 필터) |
|
|
242
|
+
| `f` | Follow 모드 토글 |
|
|
243
|
+
| `n` | 노이즈 필터 토글 (`_next`, `favicon` 등) |
|
|
244
|
+
| `e` | 에러만 표시 토글 |
|
|
245
|
+
| `x` | 트래픽 + 필터 전체 초기화 |
|
|
246
|
+
| `r` | 선택된 요청 재전송 (원본 헤더 포함) |
|
|
247
|
+
| `y` | 선택된 요청을 curl로 시스템 클립보드 복사 |
|
|
248
|
+
|
|
249
|
+
### 마우스
|
|
250
|
+
|
|
251
|
+
- 리스트 또는 상세 패널에서 **스크롤**
|
|
252
|
+
- 행을 **클릭**하여 선택
|
|
253
|
+
- 헤더의 필터 뱃지를 **클릭**하여 토글
|
|
254
|
+
|
|
255
|
+
## 보안
|
|
256
|
+
|
|
257
|
+
이 도구는 **개발 전용**이며, 로컬 개발 편의를 위해 의도적인 트레이드오프가 있습니다:
|
|
258
|
+
|
|
259
|
+
- **`rejectUnauthorized: false`** — 업스트림 타깃의 자체 서명 인증서를 허용합니다. mkcert나 자체 서명 인증서를 사용하는 개발 서비스가 추가 설정 없이 동작하기 위한 것입니다. **프로덕션에서 사용하지 마세요.**
|
|
260
|
+
- **인증 없음** — 프록시는 기본적으로 localhost에 바인딩되며 인증 레이어가 없습니다.
|
|
261
|
+
|
|
262
|
+
## 문제 해결
|
|
263
|
+
|
|
264
|
+
### 포트가 이미 사용 중
|
|
265
|
+
|
|
266
|
+
```
|
|
267
|
+
Error: port 3000 is already in use (another dev-proxy instance may already be running)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
기존 프로세스를 종료하거나 다른 포트를 사용하세요:
|
|
271
|
+
|
|
272
|
+
```bash
|
|
273
|
+
# 찾아서 종료
|
|
274
|
+
lsof -ti :3000 | xargs kill
|
|
275
|
+
|
|
276
|
+
# 또는 포트 변경
|
|
277
|
+
echo '{ "routes": { "*": "http://localhost:3080" } }' > .dev-proxy.json
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### mkcert를 찾을 수 없음
|
|
281
|
+
|
|
282
|
+
```
|
|
283
|
+
HTTPS disabled — mkcert not found.
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
HTTPS는 선택 사항입니다. TLS 지원이 필요하면 mkcert를 설치하세요:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
brew install mkcert # macOS
|
|
290
|
+
mkcert -install
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### 빈 화면 / Raw mode 에러
|
|
294
|
+
|
|
295
|
+
`Raw mode is not supported` 메시지가 보이면 비-TTY 환경(파이프, CI 등)에서 실행 중인 것입니다. dev-proxy는 대화형 터미널이 필요합니다.
|
|
296
|
+
|
|
297
|
+
### 요청이 예상 타깃으로 라우팅되지 않음
|
|
298
|
+
|
|
299
|
+
1. 스플래시 화면에서 설정된 라우트 목록을 확인하세요
|
|
300
|
+
2. `Host` 헤더가 `subdomain.domain:port` 형식과 일치하는지 확인하세요
|
|
301
|
+
3. 타깃 서비스가 설정된 포트에서 실제로 실행 중인지 확인하세요
|
|
302
|
+
|
|
303
|
+
## CLI 레퍼런스
|
|
304
|
+
|
|
305
|
+
| 명령어 | 설명 |
|
|
306
|
+
| -------------------------------------- | ------------------------------------------ |
|
|
307
|
+
| `dev-proxy` | 프록시 시작 + 트래픽 인스펙터 |
|
|
308
|
+
| `dev-proxy init` | 인터랙티브 설정 위자드 |
|
|
309
|
+
| `dev-proxy status` | 현재 설정 및 라우팅 테이블 |
|
|
310
|
+
| `dev-proxy doctor` | 환경 진단 |
|
|
311
|
+
| `dev-proxy config` | 글로벌 설정 조회 |
|
|
312
|
+
| `dev-proxy config set <key> <value>` | 글로벌 설정 수정 (domain, port, httpsPort) |
|
|
313
|
+
| `dev-proxy project add [path]` | 프로젝트 등록 (기본: cwd) |
|
|
314
|
+
| `dev-proxy project remove <path>` | 프로젝트 해제 |
|
|
315
|
+
| `dev-proxy project list` | 등록된 프로젝트 목록 |
|
|
316
|
+
| `dev-proxy worktree create <branch>` | 워크트리 생성 (자동 포트 + 훅 실행) |
|
|
317
|
+
| `dev-proxy worktree destroy <branch>` | 워크트리 제거 (훅 실행 + 정리) |
|
|
318
|
+
| `dev-proxy worktree add <name> <port>` | 워크트리 수동 등록 (git 조작 없음) |
|
|
319
|
+
| `dev-proxy worktree remove <name>` | 워크트리 수동 해제 |
|
|
320
|
+
| `dev-proxy worktree list` | 워크트리 목록 |
|
|
321
|
+
| `dev-proxy --help` | 도움말 |
|
|
322
|
+
| `dev-proxy --version` | 버전 |
|
|
323
|
+
|
|
324
|
+
## 구조
|
|
325
|
+
|
|
326
|
+
```
|
|
327
|
+
src/
|
|
328
|
+
├── cli.ts # 서브커맨드 라우터
|
|
329
|
+
├── index.tsx # TUI 대시보드 (Ink render + 프록시 라이프사이클)
|
|
330
|
+
├── store.ts # 외부 스토어 (useSyncExternalStore)
|
|
331
|
+
├── commands/ # CLI 서브커맨드 (Ink 컴포넌트)
|
|
332
|
+
│ ├── init.tsx # 인터랙티브 설정 위자드
|
|
333
|
+
│ ├── status.tsx # 설정 개요
|
|
334
|
+
│ ├── doctor.tsx # 환경 진단
|
|
335
|
+
│ ├── config.tsx # 설정 조회/수정
|
|
336
|
+
│ ├── project.tsx # 프로젝트 관리
|
|
337
|
+
│ ├── worktree.tsx # 워크트리 관리
|
|
338
|
+
│ ├── help.tsx # 도움말
|
|
339
|
+
│ └── version.tsx # 버전
|
|
340
|
+
├── bootstrap.ts # 시작 부트스트래퍼 (설정 로드, 프록시 초기화)
|
|
341
|
+
├── cli/ # 공용 CLI 컴포넌트
|
|
342
|
+
│ ├── config-io.ts # 설정 I/O 헬퍼 및 포트 할당
|
|
343
|
+
│ └── output.tsx # 출력 컴포넌트 (Header, Section, Check 등)
|
|
344
|
+
├── proxy/
|
|
345
|
+
│ ├── config.ts # 설정 로더 (~/.dev-proxy + .dev-proxy.json)
|
|
346
|
+
│ ├── server.ts # HTTP/WS 리버스 프록시
|
|
347
|
+
│ ├── routes.ts # 서브도메인 → 타깃 라우팅
|
|
348
|
+
│ ├── certs.ts # TLS 인증서 해석 (mkcert)
|
|
349
|
+
│ ├── worktrees.ts # 동적 워크트리 포트 레지스트리
|
|
350
|
+
│ └── types.ts # 이벤트 타입
|
|
351
|
+
├── components/
|
|
352
|
+
│ ├── app.tsx # 루트 (리사이즈, 키보드, 상태 머신)
|
|
353
|
+
│ ├── splash.tsx # 스플래시 화면
|
|
354
|
+
│ ├── status-bar.tsx # 상단 상태바
|
|
355
|
+
│ ├── request-list.tsx # 요청 목록 (뷰포트 슬라이싱)
|
|
356
|
+
│ ├── detail-panel.tsx # 상세 패널 (스크롤)
|
|
357
|
+
│ └── footer-bar.tsx # 하단 키바인딩 힌트
|
|
358
|
+
├── hooks/
|
|
359
|
+
│ └── use-mouse.ts # SGR 마우스 이벤트 파서
|
|
360
|
+
└── utils/
|
|
361
|
+
├── format.ts # 색상 팔레트, 포매터
|
|
362
|
+
└── list-layout.ts # 반응형 컬럼 레이아웃
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## 기여
|
|
366
|
+
|
|
367
|
+
개발 설정과 가이드라인은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
|
368
|
+
|
|
369
|
+
## 라이선스
|
|
370
|
+
|
|
371
|
+
[MIT](LICENSE)
|
package/bin/dev-proxy.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared config file I/O for CLI commands.
|
|
3
|
+
* Re-exports constants from proxy/config.ts and provides read/write helpers.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { CONFIG_DIR, GLOBAL_CONFIG_PATH, PROJECT_CONFIG_NAME } from "../proxy/config.js";
|
|
8
|
+
export { CONFIG_DIR, GLOBAL_CONFIG_PATH, PROJECT_CONFIG_NAME };
|
|
9
|
+
/** Write to a temp file then atomically rename — prevents corruption on crash. */
|
|
10
|
+
function atomicWriteFileSync(filePath, data) {
|
|
11
|
+
const tmp = filePath + ".tmp";
|
|
12
|
+
writeFileSync(tmp, data, "utf-8");
|
|
13
|
+
try {
|
|
14
|
+
renameSync(tmp, filePath);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
try {
|
|
18
|
+
unlinkSync(tmp);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* best-effort cleanup */
|
|
22
|
+
}
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function readGlobalConfig() {
|
|
27
|
+
try {
|
|
28
|
+
if (existsSync(GLOBAL_CONFIG_PATH)) {
|
|
29
|
+
return JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
console.warn(`[dev-proxy] Failed to parse ${GLOBAL_CONFIG_PATH}: ${err.message}`);
|
|
34
|
+
}
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
export function writeGlobalConfig(cfg) {
|
|
38
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
39
|
+
atomicWriteFileSync(GLOBAL_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n");
|
|
40
|
+
}
|
|
41
|
+
/** Extract all ports from a worktree entry */
|
|
42
|
+
export function getEntryPorts(entry) {
|
|
43
|
+
if ("ports" in entry)
|
|
44
|
+
return Object.values(entry.ports);
|
|
45
|
+
return [entry.port];
|
|
46
|
+
}
|
|
47
|
+
/** Get port for a specific service, with legacy fallback */
|
|
48
|
+
export function getServicePort(entry, service) {
|
|
49
|
+
if ("ports" in entry) {
|
|
50
|
+
if (service && service in entry.ports)
|
|
51
|
+
return entry.ports[service];
|
|
52
|
+
// Fallback: first port
|
|
53
|
+
const values = Object.values(entry.ports);
|
|
54
|
+
return values[0] ?? null;
|
|
55
|
+
}
|
|
56
|
+
// Legacy single port
|
|
57
|
+
return entry.port;
|
|
58
|
+
}
|
|
59
|
+
export function readProjectConfig(projectPath) {
|
|
60
|
+
const configPath = resolve(projectPath, PROJECT_CONFIG_NAME);
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(configPath)) {
|
|
63
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.warn(`[dev-proxy] Failed to parse ${configPath}: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
export function writeProjectConfig(projectPath, cfg) {
|
|
72
|
+
const configPath = resolve(projectPath, PROJECT_CONFIG_NAME);
|
|
73
|
+
atomicWriteFileSync(configPath, JSON.stringify(cfg, null, 2) + "\n");
|
|
74
|
+
}
|
|
75
|
+
// ── Validation ───────────────────────────────────────────────
|
|
76
|
+
export function isValidPort(value) {
|
|
77
|
+
return Number.isInteger(value) && value > 0 && value <= 65535;
|
|
78
|
+
}
|
|
79
|
+
const SUBDOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
80
|
+
/** Validate a subdomain label (lowercase alphanumeric + hyphens, no leading/trailing hyphen). */
|
|
81
|
+
export function isValidSubdomain(value) {
|
|
82
|
+
return value === "*" || SUBDOMAIN_RE.test(value);
|
|
83
|
+
}
|
|
84
|
+
// ── Port allocation ──────────────────────────────────────────
|
|
85
|
+
export function allocatePort(portRange, usedPorts) {
|
|
86
|
+
for (let p = portRange[0]; p <= portRange[1]; p++) {
|
|
87
|
+
if (!usedPorts.has(p))
|
|
88
|
+
return p;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
export function allocatePorts(count, portRange, usedPorts) {
|
|
93
|
+
const result = [];
|
|
94
|
+
for (let p = portRange[0]; p <= portRange[1] && result.length < count; p++) {
|
|
95
|
+
if (!usedPorts.has(p))
|
|
96
|
+
result.push(p);
|
|
97
|
+
}
|
|
98
|
+
return result.length === count ? result : null;
|
|
99
|
+
}
|
|
100
|
+
// ── Env file generation ──────────────────────────────────────
|
|
101
|
+
export function generateEnvContent(services, ports) {
|
|
102
|
+
const lines = [];
|
|
103
|
+
for (const [subdomain, { env }] of Object.entries(services)) {
|
|
104
|
+
const port = ports[subdomain];
|
|
105
|
+
if (port !== undefined) {
|
|
106
|
+
lines.push(`${env}=${port}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines.join("\n") + "\n";
|
|
110
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Box, Text, useApp } from "ink";
|
|
4
|
+
const DIVIDER = "─".repeat(44);
|
|
5
|
+
export function Header({ text }) {
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, children: ` ${text}` }), _jsx(Text, { dimColor: true, children: ` ${DIVIDER}` })] }));
|
|
7
|
+
}
|
|
8
|
+
export function Section({ title, children, }) {
|
|
9
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, color: "white", children: ` ${title}` }), children] }));
|
|
10
|
+
}
|
|
11
|
+
export function Row({ label, value, pad = 14, }) {
|
|
12
|
+
return (_jsxs(Text, { children: [" ", _jsx(Text, { dimColor: true, children: label.padEnd(pad) }), _jsx(Text, { children: value })] }));
|
|
13
|
+
}
|
|
14
|
+
export function RouteRow({ sub, target, pad = 14, }) {
|
|
15
|
+
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: sub === "*" ? "yellow" : "cyan", children: sub.padEnd(pad) }), _jsx(Text, { dimColor: true, children: "\u279C " }), _jsx(Text, { children: target })] }));
|
|
16
|
+
}
|
|
17
|
+
export function Check({ ok, warn, label, }) {
|
|
18
|
+
const symbol = ok ? "\u2713" : warn ? "\u26A0" : "\u2717";
|
|
19
|
+
const color = ok ? "green" : warn ? "yellow" : "red";
|
|
20
|
+
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: color, children: symbol }), _jsx(Text, { children: ` ${label}` })] }));
|
|
21
|
+
}
|
|
22
|
+
export function Hint({ text }) {
|
|
23
|
+
return _jsx(Text, { dimColor: true, children: ` ${text}` });
|
|
24
|
+
}
|
|
25
|
+
export function ErrorMessage({ message, hint }) {
|
|
26
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [" ", _jsx(Text, { color: "red", children: "\u2717" }), _jsx(Text, { children: ` ${message}` })] }), hint && _jsx(Hint, { text: hint })] }));
|
|
27
|
+
}
|
|
28
|
+
export function SuccessMessage({ message }) {
|
|
29
|
+
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "green", children: "\u2713" }), _jsx(Text, { children: ` ${message}` })] }));
|
|
30
|
+
}
|
|
31
|
+
export function ExitOnRender() {
|
|
32
|
+
const { exit } = useApp();
|
|
33
|
+
useState(() => {
|
|
34
|
+
setTimeout(exit, 0);
|
|
35
|
+
});
|
|
36
|
+
return null;
|
|
37
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const KNOWN_COMMANDS = [
|
|
2
|
+
"init",
|
|
3
|
+
"status",
|
|
4
|
+
"doctor",
|
|
5
|
+
"config",
|
|
6
|
+
"project",
|
|
7
|
+
"worktree",
|
|
8
|
+
];
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const command = args[0];
|
|
11
|
+
// Global flags — take priority over subcommands
|
|
12
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
13
|
+
await import("./commands/help.js");
|
|
14
|
+
}
|
|
15
|
+
else if (args.includes("--version") || args.includes("-v")) {
|
|
16
|
+
await import("./commands/version.js");
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
switch (command) {
|
|
20
|
+
case undefined:
|
|
21
|
+
await import("./index.js");
|
|
22
|
+
break;
|
|
23
|
+
case "init":
|
|
24
|
+
await import("./commands/init.js");
|
|
25
|
+
break;
|
|
26
|
+
case "status":
|
|
27
|
+
await import("./commands/status.js");
|
|
28
|
+
break;
|
|
29
|
+
case "doctor":
|
|
30
|
+
await import("./commands/doctor.js");
|
|
31
|
+
break;
|
|
32
|
+
case "config":
|
|
33
|
+
await import("./commands/config.js");
|
|
34
|
+
break;
|
|
35
|
+
case "project":
|
|
36
|
+
await import("./commands/project.js");
|
|
37
|
+
break;
|
|
38
|
+
case "worktree":
|
|
39
|
+
await import("./commands/worktree.js");
|
|
40
|
+
break;
|
|
41
|
+
default: {
|
|
42
|
+
// Unknown command — suggest closest match
|
|
43
|
+
const suggestion = closest(command, KNOWN_COMMANDS);
|
|
44
|
+
console.error(`\n \x1b[31m\u2717\x1b[0m Unknown command: ${command}`);
|
|
45
|
+
if (suggestion) {
|
|
46
|
+
console.error(`\n Did you mean \x1b[36m${suggestion}\x1b[0m?`);
|
|
47
|
+
}
|
|
48
|
+
console.error(`\n Run \x1b[2mdev-proxy --help\x1b[0m for available commands.\n`);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function closest(input, candidates) {
|
|
54
|
+
let best = null;
|
|
55
|
+
let bestDist = Infinity;
|
|
56
|
+
for (const c of candidates) {
|
|
57
|
+
const d = levenshtein(input, c);
|
|
58
|
+
if (d < bestDist && d <= 3) {
|
|
59
|
+
bestDist = d;
|
|
60
|
+
best = c;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return best;
|
|
64
|
+
}
|
|
65
|
+
export function levenshtein(a, b) {
|
|
66
|
+
const m = a.length;
|
|
67
|
+
const n = b.length;
|
|
68
|
+
const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
|
|
69
|
+
for (let i = 1; i <= m; i++) {
|
|
70
|
+
for (let j = 1; j <= n; j++) {
|
|
71
|
+
dp[i][j] =
|
|
72
|
+
a[i - 1] === b[j - 1]
|
|
73
|
+
? dp[i - 1][j - 1]
|
|
74
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return dp[m][n];
|
|
78
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, render } from "ink";
|
|
3
|
+
import { readGlobalConfig, writeGlobalConfig } from "../cli/config-io.js";
|
|
4
|
+
import { Header, Row, SuccessMessage, ErrorMessage, ExitOnRender, } from "../cli/output.js";
|
|
5
|
+
// ── Show current config ──────────────────────────────────────
|
|
6
|
+
function ConfigView() {
|
|
7
|
+
const cfg = readGlobalConfig();
|
|
8
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), _jsx(Header, { text: "Global Configuration" }), _jsx(Row, { label: "domain", value: cfg.domain ?? "localhost" }), _jsx(Row, { label: "port", value: String(cfg.port ?? 3000) }), _jsx(Row, { label: "httpsPort", value: String(cfg.httpsPort ?? 3443) }), _jsx(Row, { label: "projects", value: cfg.projects && cfg.projects.length > 0 ? cfg.projects.join(", ") : "(none)" })] }));
|
|
9
|
+
}
|
|
10
|
+
// ── Set a config key ─────────────────────────────────────────
|
|
11
|
+
const VALID_KEYS = new Set(["domain", "port", "httpsPort"]);
|
|
12
|
+
function ConfigSet({ configKey, value }) {
|
|
13
|
+
let message;
|
|
14
|
+
let error;
|
|
15
|
+
let hint;
|
|
16
|
+
if (!VALID_KEYS.has(configKey)) {
|
|
17
|
+
error = `Unknown config key "${configKey}"`;
|
|
18
|
+
hint = `Supported keys: ${[...VALID_KEYS].join(", ")}`;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const cfg = readGlobalConfig();
|
|
22
|
+
if (configKey === "domain") {
|
|
23
|
+
cfg.domain = value;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
const num = Number(value);
|
|
27
|
+
if (!Number.isInteger(num) || num <= 0 || num > 65535) {
|
|
28
|
+
error = `Invalid port value "${value}"`;
|
|
29
|
+
hint = "Expected an integer between 1 and 65535";
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
if (configKey === "port")
|
|
33
|
+
cfg.port = num;
|
|
34
|
+
else
|
|
35
|
+
cfg.httpsPort = num;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!error) {
|
|
39
|
+
writeGlobalConfig(cfg);
|
|
40
|
+
message = `Set ${configKey} = ${value}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ExitOnRender, {}), message && _jsx(SuccessMessage, { message: message }), error && _jsx(ErrorMessage, { message: error, hint: hint })] }));
|
|
44
|
+
}
|
|
45
|
+
// ── Entry point ──────────────────────────────────────────────
|
|
46
|
+
const args = process.argv.slice(3);
|
|
47
|
+
const subcommand = args[0];
|
|
48
|
+
if (subcommand === "set") {
|
|
49
|
+
const key = args[1];
|
|
50
|
+
const value = args[2];
|
|
51
|
+
if (!key || !value) {
|
|
52
|
+
render(_jsx(ErrorMessage, { message: "Usage: dev-proxy config set <key> <value>", hint: `Supported keys: ${[...VALID_KEYS].join(", ")}` }));
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
render(_jsx(ConfigSet, { configKey: key, value: value }));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
render(_jsx(ConfigView, {}));
|
|
60
|
+
}
|