@gaonjs/cli 0.13.0 → 0.13.2

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.
@@ -1,16 +1,22 @@
1
1
  <script setup lang="ts">
2
- import { pageProps } from 'gaonjs/vue'
2
+ import { pageProps, router } from 'gaonjs/vue'
3
3
 
4
4
  // dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
5
- const { user } = pageProps<'{{APP_NAME}}:dashboard#show'>()
5
+ const { user, csrf } = pageProps<'{{APP_NAME}}:dashboard#show'>()
6
+
7
+ // 로그아웃 = DELETE /session (r.resource('session') 의 destroy).
8
+ // HTML <form> 은 DELETE 를 보낼 수 없으므로 Inertia 라우터로 실제 메서드를
9
+ // 보낸다(결정 64) — `?_method=DELETE` 우회는 서버가 해석하지 않아 POST /session
10
+ // (= 로그인 create) 으로 잘못 라우팅됐다.
11
+ function logout(): void {
12
+ router.delete('/session', { headers: { 'x-csrf-token': csrf } })
13
+ }
6
14
  </script>
7
15
 
8
16
  <template>
9
17
  <main>
10
18
  <h1>환영합니다, {{ user.name }}님</h1>
11
19
  <p>{{ user.email }}</p>
12
- <form method="post" action="/session?_method=DELETE">
13
- <button type="submit">로그아웃</button>
14
- </form>
20
+ <button type="button" @click="logout">로그아웃</button>
15
21
  </main>
16
22
  </template>
@@ -1,18 +1,21 @@
1
1
  <script setup lang="ts">
2
- import { pageProps } from 'gaonjs/vue'
2
+ import { pageProps, useForm } from 'gaonjs/vue'
3
3
 
4
4
  // 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2).
5
5
  const { error, csrf } = pageProps<'{{APP_NAME}}:session#new'>()
6
+
7
+ // 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
8
+ // 서버는 redirect(Inertia 응답)로 답하고, 실패 시 같은 페이지를 다시 render 한다.
9
+ const form = useForm({ email: '', password: '', _csrf: csrf })
6
10
  </script>
7
11
 
8
12
  <template>
9
- <form method="post" action="/session">
10
- <input type="hidden" name="_csrf" :value="csrf" />
13
+ <form @submit.prevent="form.post('/session')">
11
14
  <h1>로그인</h1>
12
15
  <p v-if="error" class="error">{{ error }}</p>
13
- <label>이메일 <input name="email" type="email" required /></label>
14
- <label>비밀번호 <input name="password" type="password" required /></label>
15
- <button type="submit">로그인</button>
16
+ <label>이메일 <input v-model="form.email" type="email" required /></label>
17
+ <label>비밀번호 <input v-model="form.password" type="password" required /></label>
18
+ <button type="submit" :disabled="form.processing">로그인</button>
16
19
  <a href="/registration/new">회원가입</a>
17
20
  </form>
18
21
  </template>
@@ -1,18 +1,20 @@
1
1
  <script setup lang="ts">
2
- import { pageProps } from 'gaonjs/vue'
2
+ import { pageProps, useForm } from 'gaonjs/vue'
3
3
 
4
4
  const { error, csrf } = pageProps<'{{APP_NAME}}:registration#new'>()
5
+
6
+ // 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
7
+ const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
5
8
  </script>
6
9
 
7
10
  <template>
8
- <form method="post" action="/registration">
9
- <input type="hidden" name="_csrf" :value="csrf" />
11
+ <form @submit.prevent="form.post('/registration')">
10
12
  <h1>회원가입</h1>
11
13
  <p v-if="error" class="error">{{ error }}</p>
12
- <label>이름 <input name="name" required /></label>
13
- <label>이메일 <input name="email" type="email" required /></label>
14
- <label>비밀번호 <input name="password" type="password" required /></label>
15
- <button type="submit">회원가입</button>
14
+ <label>이름 <input v-model="form.name" required /></label>
15
+ <label>이메일 <input v-model="form.email" type="email" required /></label>
16
+ <label>비밀번호 <input v-model="form.password" type="password" required /></label>
17
+ <button type="submit" :disabled="form.processing">회원가입</button>
16
18
  <a href="/session/new">로그인</a>
17
19
  </form>
18
20
  </template>
@@ -5,6 +5,7 @@ export default controller({
5
5
  // GET /dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
6
6
  async show() {
7
7
  const user = this.requireAuth()
8
- return this.render('Dashboard', { user })
8
+ // csrf 는 로그아웃(DELETE /session · 결정 64)이 헤더로 실어 보낸다.
9
+ return this.render('Dashboard', { user, csrf: this.csrfToken() })
9
10
  },
10
11
  })
@@ -67,8 +67,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
67
67
  설정으로만 (`agents/security.md`).
68
68
  5. **테스트는 Docker 실인프라 필수** — DB·NATS 목업·인메모리 대체 절대
69
69
  금지 (§9 · `agents/testing.md`).
70
- 6. **인증·폼은 Inertia SPA 방식** (SSR 아님). 폼은 `Inertia.post()`
71
- 서버 redirect. REST + `fetch()` **API (JWT) 전용**.
70
+ 6. **인증·폼은 Inertia SPA 방식** (SSR 아님). 폼은 `gaonjs/vue`
71
+ `useForm(...).post(...)` 서버 redirect (HTML 폼이 못 보내는 메서드는
72
+ `router.delete(...)` 등). REST + `fetch()` 는 **API 앱(JWT) 전용**.
72
73
  7. **한 액션은 한 종류 응답만** (render 또는 JSON 또는 redirect —
73
74
  혼용 금지 · doctor response-mixing).
74
75
  8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`tables.d.ts`
@@ -434,6 +434,28 @@ gaon db seed # domain/seed.ts 실행
434
434
  **파일명 순으로 실제 실행**(replay)하고, ② 그 뒤 스키마 diff 로 나머지를
435
435
  적용한다.
436
436
 
437
+ - **replay 가 diff 보다 먼저다 — 손작성 마이그는 "아직 diff 가 안 만든 테이블"을
438
+ 참조할 수 없다** (결정 65). 스키마에만 선언돼 있고 아직 DB 에 없는 테이블을
439
+ FK 로 거는 마이그레이션은 **빈 DB 에서만** `relation "posts" does not exist` 로
440
+ 실패한다(테이블이 이미 있는 개발 DB 에서는 통과 — 그래서 CI·새 클론에서만
441
+ 터진다). 조인 테이블처럼 남의 테이블을 참조해야 하면 **참조 대상도 같은/앞선
442
+ 마이그 파일에서 함께 만든다**:
443
+
444
+ ```ts
445
+ // 20260728_add_tags.ts — posts 를 FK 로 걸려면 posts 가 먼저 존재해야 한다.
446
+ export async function up(db: Kysely<any>): Promise<void> {
447
+ await db.schema.createTable('tags')/* … */.execute()
448
+ await db.schema.createTable('posts_tags')
449
+ .addColumn('postId', 'bigint', (c) => c.notNull().references('posts.id').onDelete('cascade'))
450
+ // ↑ posts 가 스키마에만 있고 DB 에 없으면 빈 DB 에서 실패한다.
451
+ .execute()
452
+ }
453
+ ```
454
+
455
+ 전부 스키마 우선으로 두면(손작성 마이그 없이) diff 가 의존 순서를 알아서
456
+ 잡으므로 이 함정 자체가 없다 — **손작성 마이그는 diff 로 표현 못 하는 것에만**
457
+ 쓴다(rename·백필·수동 DDL).
458
+
437
459
  - **손작성 마이그는 `db/migrations/<파일명>.ts`** 에 두고 `up(db)`·`down(db)`
438
460
  (Kysely) 를 export 한다. **파일명이 곧 버전 키** — `0001_add_posts.ts`
439
461
  시퀀스든 `20260724_add_posts.ts` 타임스탬프든 사전순 정렬이 안정적이면 된다
@@ -94,14 +94,22 @@ const members = await ctx.presence()
94
94
  브라우저는 표준 WebSocket 으로 채널에 접속한다. 세션 앱은 세션 쿠키로,
95
95
  JWT 앱은 쿼리(`access_token`)로 인증한다.
96
96
 
97
+ **경로는 `<앱 프리픽스>/gaon/ws/<채널명>`** 이고, 보내는 프레임은 `{ t: 'msg',
98
+ data }` **봉투** 다 — 날 페이로드를 그대로 보내면 서버가 `onMessage` 로
99
+ 흘리지 않는다. 받는 프레임도 같은 모양(`t` 로 종류를 가른다).
100
+
97
101
  ```ts
98
- // 브라우저측 (요지) — 채널 구독 래핑은 컴포저블에 (agents/frontend.md)
99
- const ws = new WebSocket(`wss://example.com/channels/room?room=42`)
102
+ // 브라우저측 — 채널 구독 래핑은 컴포저블에 (agents/frontend.md)
103
+ // web 앱(프리픽스 /)의 room 채널 = /gaon/ws/room. admin 앱이면 /admin/gaon/ws/room.
104
+ const ws = new WebSocket(`wss://example.com/gaon/ws/room?room=42`)
100
105
  ws.onmessage = (ev) => {
101
- const msg = JSON.parse(ev.data)
102
- // 서버가 broadcast/send 데이터 · 프레즌스 델타
106
+ const frame = JSON.parse(ev.data) // { t: 'msg' | 'presence' | ... , data }
107
+ if (frame.t === 'msg') {
108
+ // 서버가 broadcast/send 한 데이터
109
+ }
103
110
  }
104
- ws.send(JSON.stringify({ text: '안녕하세요' })) // onMessage 전달
111
+ // 보낼 때도 봉투로 감싼다 — 이래야 채널의 onMessage 호출된다.
112
+ ws.send(JSON.stringify({ t: 'msg', data: { text: '안녕하세요' } }))
105
113
  ```
106
114
 
107
115
  ### 5. 허브 프로세스 (`gaon hub`)
@@ -105,10 +105,26 @@ export default controller({
105
105
  데이터가 필요할 때는 루트 `AGENTS.md` 의 데이터 경로 4종 판단표를 따른다
106
106
  (Inertia partial reload · 채널/프레즌스 · JSON 액션 + `api()` · 별도 API 앱 + JWT).
107
107
  **Inertia = SPA + 서버 라우팅**이지 SSR 이 아니다. 로그인·회원가입도 컨트롤러
108
- `this.render('auth/Login')` + Vue 페이지 `Inertia.post()` → 서버
108
+ `this.render('Auth/Login')` + Vue 페이지의 `useForm(...).post(...)` → 서버
109
109
  redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드포인트도
110
110
  없다. REST + `fetch()` 는 **API 앱(JWT) 전용**.
111
111
 
112
+ 폼 API 는 **`gaonjs/vue` 의 `useForm`·`router`** 뿐이다(결정 64) —
113
+ `@inertiajs/vue3` 를 직접 import 하지 않는다(파사드가 The One Way).
114
+
115
+ ```ts
116
+ // 로그인 폼 — 제출은 Inertia SPA 방식, 서버는 redirect 로 답한다.
117
+ const { error, csrf } = pageProps<'web:session#new'>()
118
+ const form = useForm({ email: '', password: '', _csrf: csrf })
119
+ // <form @submit.prevent="form.post('/session')">
120
+
121
+ // HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로 보낸다.
122
+ router.delete('/session', { headers: { 'x-csrf-token': csrf } })
123
+ ```
124
+
125
+ `?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
126
+ 엉뚱한 액션(create)에 도달한다.
127
+
112
128
  ### 5. 비밀번호 해싱 — `hashPassword` · `verifyPassword` (`gaonjs/web`)
113
129
 
114
130
  회원가입·로그인에서 비밀번호를 다룰 때는 **직접 crypto/bcrypt 를 import 하거나
@@ -156,6 +172,35 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
156
172
  }
157
173
  ```
158
174
 
175
+ - **세션을 확정·해제하는 것은 `this.auth`** (결정 65). `requireAuth()` 는 이미
176
+ 로그인된 요청을 **지키는** 쪽이고, 로그인 자체를 **성립시키는** 호출은 따로다:
177
+
178
+ | 호출 | 하는 일 |
179
+ |---|---|
180
+ | `this.auth.user` | 현재 사용자 (`GaonCurrentUser \| null`) |
181
+ | `this.auth.login(user)` | 세션에 사용자 id 를 심어 로그인 상태로 만든다 |
182
+ | `this.auth.logout()` | 세션에서 사용자를 지운다 |
183
+
184
+ ```ts
185
+ // 로그인 — 폼 액션의 유일한 render+redirect 혼용 예외(결정 57 보완)
186
+ async create() {
187
+ const { email, password } = this.params({ _row: {} as { email: string; password: string } })
188
+ const user = await User.where('email', '=', email).first()
189
+ if (!user || !(await verifyPassword(password, user.passwordDigest))) {
190
+ return this.render('Auth/Login', { error: '이메일 또는 비밀번호가 올바르지 않습니다.', csrf: this.csrfToken() })
191
+ }
192
+ this.auth.login(user) // 세션 확정
193
+ return this.redirect('/dashboard')
194
+ }
195
+ // 로그아웃 — DELETE /session (페이지에서 router.delete 로 호출 · 결정 64)
196
+ async destroy() {
197
+ this.auth.logout()
198
+ return this.redirect('/session/new')
199
+ }
200
+ ```
201
+
202
+ API 앱(JWT)은 세션 대신 `this.jwt.issue(user)` / `this.jwt.refresh(token)` 를 쓴다.
203
+
159
204
  - **`this.auth.user`/`requireAuth()` 사용자 타입은 앱이 증강한다** (결정 58).
160
205
  `GaonCurrentUser` 는 빈 인터페이스라 증강 없이는 `user.id` 접근이 타입에러다.
161
206
  `gaon g auth` 가 `apps/<app>/auth.ts` 에 심는다:
@@ -220,8 +265,8 @@ export default controller({
220
265
  - **render/JSON/redirect 를 한 액션에서 조건 혼용하면 doctor
221
266
  response-mixing 위반** — 액션을 나눈다. 예외는 폼 액션의
222
267
  "실패 render + 성공 redirect" 조합 하나뿐(결정 57 보완).
223
- - **`fetch()` 로 로그인 폼 구현 금지** — 세션 앱 폼은 `Inertia.post()`.
224
- REST + fetch 는 API 앱(JWT) 전용.
268
+ - **`fetch()` 로 로그인 폼 구현 금지** — 세션 앱 폼은 `gaonjs/vue` 의
269
+ `useForm(...).post(...)`(결정 64). REST + fetch 는 API 앱(JWT) 전용.
225
270
  - **컨트롤러에 비즈니스 로직 인라인 금지** (§5.3 One Way) — 여러 모델·
226
271
  트랜잭션·외부 API 가 얽히면 `domain/services/`.
227
272
  - **컨트롤러에서 메일·외부 발송 직접 호출 금지** — 잡 발행만
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,11 +28,11 @@
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
30
  "@gaonjs/async": "0.3.1",
31
- "@gaonjs/config": "0.3.0",
32
- "@gaonjs/web": "0.5.3",
31
+ "@gaonjs/core": "0.1.4",
33
32
  "@gaonjs/mail": "0.1.1",
34
33
  "@gaonjs/data": "0.8.4",
35
- "@gaonjs/core": "0.1.4"
34
+ "@gaonjs/web": "0.5.3",
35
+ "@gaonjs/config": "0.3.0"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""