@gaonjs/cli 0.15.0 → 0.21.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.
Files changed (69) hide show
  1. package/dist/commands/g.js +8 -0
  2. package/dist/commands/new.js +5 -0
  3. package/dist/dev/health.d.ts +4 -1
  4. package/dist/dev/health.js +5 -3
  5. package/dist/doctor/method-override.d.ts +5 -0
  6. package/dist/doctor/method-override.js +75 -0
  7. package/dist/doctor/route-registration.d.ts +5 -0
  8. package/dist/doctor/route-registration.js +77 -0
  9. package/dist/doctor/static-collision.d.ts +5 -0
  10. package/dist/doctor/static-collision.js +84 -0
  11. package/dist/doctor/types.d.ts +1 -1
  12. package/dist/doctor/types.js +3 -3
  13. package/dist/doctor/ui-kit-wiring.d.ts +5 -0
  14. package/dist/doctor/ui-kit-wiring.js +93 -0
  15. package/dist/doctor.d.ts +1 -0
  16. package/dist/doctor.js +19 -2
  17. package/dist/generate.js +8 -0
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.js +19 -5
  20. package/dist/scaffold/app-wiring.d.ts +12 -0
  21. package/dist/scaffold/app-wiring.js +68 -0
  22. package/dist/serve.d.ts +10 -0
  23. package/dist/serve.js +90 -2
  24. package/dist/templates/auth/Dashboard.vue.tpl +21 -5
  25. package/dist/templates/auth/Login.vue.tpl +37 -8
  26. package/dist/templates/auth/Signup.vue.tpl +40 -9
  27. package/dist/templates/project/.dockerignore.tpl +12 -0
  28. package/dist/templates/project/AGENTS.md.tpl +10 -5
  29. package/dist/templates/project/CLAUDE.md.tpl +4 -3
  30. package/dist/templates/project/Dockerfile.tpl +30 -0
  31. package/dist/templates/project/agents/async.md.tpl +19 -7
  32. package/dist/templates/project/agents/data.md.tpl +37 -15
  33. package/dist/templates/project/agents/frontend.md.tpl +81 -4
  34. package/dist/templates/project/agents/realtime.md.tpl +24 -17
  35. package/dist/templates/project/agents/security.md.tpl +11 -2
  36. package/dist/templates/project/agents/web.md.tpl +5 -0
  37. package/dist/templates/project/apps/web/composables/useGaonHealth.ts.tpl +2 -0
  38. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +19 -94
  39. package/dist/templates/project/apps/web/main.ts.tpl +4 -0
  40. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +68 -251
  41. package/dist/templates/project/apps/web/static/robots.txt.tpl +4 -0
  42. package/dist/templates/project/apps/web/style.css.tpl +66 -0
  43. package/dist/templates/project/compose.prod.yaml.tpl +98 -0
  44. package/dist/templates/project/package.json.tpl +4 -0
  45. package/dist/templates/project/postcss.config.js.tpl +15 -0
  46. package/dist/templates/project/tailwind.config.ts.tpl +56 -0
  47. package/dist/templates/ui-kit/Alert.vue.tpl +23 -0
  48. package/dist/templates/ui-kit/AlertDescription.vue.tpl +9 -0
  49. package/dist/templates/ui-kit/AlertTitle.vue.tpl +9 -0
  50. package/dist/templates/ui-kit/Badge.vue.tpl +25 -0
  51. package/dist/templates/ui-kit/Button.vue.tpl +39 -0
  52. package/dist/templates/ui-kit/Card.vue.tpl +10 -0
  53. package/dist/templates/ui-kit/CardContent.vue.tpl +9 -0
  54. package/dist/templates/ui-kit/CardDescription.vue.tpl +9 -0
  55. package/dist/templates/ui-kit/CardFooter.vue.tpl +9 -0
  56. package/dist/templates/ui-kit/CardHeader.vue.tpl +9 -0
  57. package/dist/templates/ui-kit/CardTitle.vue.tpl +9 -0
  58. package/dist/templates/ui-kit/Dialog.vue.tpl +68 -0
  59. package/dist/templates/ui-kit/Form.vue.tpl +13 -0
  60. package/dist/templates/ui-kit/FormField.vue.tpl +16 -0
  61. package/dist/templates/ui-kit/FormMessage.vue.tpl +9 -0
  62. package/dist/templates/ui-kit/Input.vue.tpl +22 -0
  63. package/dist/templates/ui-kit/Label.vue.tpl +9 -0
  64. package/dist/templates/ui-kit/Sheet.vue.tpl +73 -0
  65. package/dist/templates/ui-kit/utils.ts.tpl +26 -0
  66. package/dist/uikit.d.ts +28 -0
  67. package/dist/uikit.js +138 -0
  68. package/dist/work.js +2 -0
  69. package/package.json +6 -6
@@ -193,8 +193,8 @@ export const posts = table('posts', {
193
193
  | `pluck` | `(col)` | `Promise<Row[col][]>` | 단일 컬럼 배열 · 정렬·limit·offset 반영 |
194
194
  | `select` | `(['a', 'b'])` | `SelectChain<Row, K>` | 부분 컬럼 — `first`/`all` 이 `Pick<Row, K>` **plain 행** 반환 (메서드·관계·update 없음) |
195
195
  | `include` | `(...rels)` | `IncludedChain` | 관계 eager 로드 — **4종 전부**(belongsTo·hasMany·hasOne·belongsToMany, §1.1). **N+1 방지**: 관계당 쿼리 1회 (belongsToMany 는 피벗 `inner join` 1회) · 행 수와 무관. doctor 의 **n-plus-one** 검사가 include 미사용 · loop 안 관계 호출을 감지한다 |
196
- | `updateAll` | `(patch)` | `Promise<bigint>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수 반환. limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
197
- | `deleteAll` | `()` | `Promise<bigint>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
196
+ | `updateAll` | `(patch)` | `Promise<number>` | **벌크 갱신** (M2C) — where 조건만 반영 · 영향 행 수(number · 결정 90). limit·offset·orderBy 가 걸려 있으면 **throw** (Postgres `UPDATE ... LIMIT` 미지원 — 행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`) |
197
+ | `deleteAll` | `()` | `Promise<number>` | **벌크 삭제** (M2C) — 규칙은 updateAll 과 동일. 빈 where = 전체 삭제 (이름이 위험을 드러냄) |
198
198
 
199
199
  **집계·조인 그룹** (`Chain` · M2E · 결정 34):
200
200
 
@@ -218,18 +218,35 @@ export const posts = table('posts', {
218
218
  | 메서드 | 시그니처 | 반환 | 비고 |
219
219
  |---|---|---|---|
220
220
  | `create` | `(data)` | `Promise<Rec>` | `t.id()`·`t.timestamps()`·`.default()` 컬럼은 입력에서 선택적 (`InsertOf`) |
221
- | `batchInsert` | `(rows)` | `Promise<Rec[]>` | **벌크 삽입** (M2F · 결정 35) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · 넣은 순서대로 Rec 반환. 빈 배열은 DB 무접촉 `[]`. beforeCreate 훅은 각 row 에 적용 |
222
- | `insertOrIgnore` | `(row \| rows)` | `Promise<Rec[]>` | **멱등 삽입** (M2F) — `ON CONFLICT DO NOTHING` · UNIQUE/PK 충돌 행은 건너뛰고 **실제로 삽입된 Rec 만** 반환. 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
223
- | `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<Rec[]>` | **있으면 갱신 없으면 삽입** (M2F) — `ON CONFLICT DO UPDATE`. `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 `excluded` 로 덮음 · 갱신 대상 없으면 DO NOTHING |
221
+ | `batchInsert` | `(rows)` | `Promise<BulkResult>` | **벌크 삽입** (M2F · 결정 35 → BulkResult 결정 90) — 여러 row 를 **단일 INSERT 문**(원자적)으로 · `{ count }`(삽입 행 수) 반환. 빈 배열은 DB 무접촉 `{ count: 0 }`. beforeCreate 훅은 각 row 에 적용 |
222
+ | `insertOrIgnore` | `(row \| rows)` | `Promise<BulkResult>` | **멱등 삽입** (M2F) — 충돌 행은 건너뛰고 `count` = **실제로 삽입된 수**(PG=`ON CONFLICT DO NOTHING` · MySQL=`INSERT IGNORE`). 충돌 대상 인자 없음(어떤 UNIQUE/PK 든 충돌 시 건너뜀) |
223
+ | `upsert` | `(row \| rows, { onConflict?, update? })` | `Promise<BulkResult>` | **있으면 갱신 없으면 삽입** (M2F) — PG=`ON CONFLICT DO UPDATE` · MySQL=`ON DUPLICATE KEY UPDATE`. `count` = 처리된 입력 행 수(삽입+갱신 · 갱신 대상 없으면 실 삽입 수). `onConflict` 생략 = 기본 키(`id`) 자동(없으면 throw) · `update` 생략(`'exclude'`) = 입력 컬럼에서 충돌 기준·`id` 뺀 나머지를 덮음 |
224
224
  | `find` | `(id)` | `Promise<Rec>` | 없으면 **throw** — undefined 를 허용하려면 `where('id', '=', id).first()` |
225
225
  | `query` | `()` | Kysely `SelectQueryBuilder` | §5 탈출구 |
226
226
 
227
- > 벌크 삽입 3종(`batchInsert`·`insertOrIgnore`·`upsert`) **RETURNING
228
- > 지원하는 커넥션(Postgres)** 에서만 된다 삽입/무시/갱신된집합을
229
- > 정확히 복원할 있는 방언이 RETURNING 뿐이라, RETURNING 커넥션에서는
230
- > 어림하지 않고 수리 안내와 함께 **throw** 한다(결정 35 · §7.5.3). 삽입 계열은
227
+ > 벌크 3종은 **전 방언 통일 `BulkResult`**(`{ count, meta? }`) 돌려준다(결정 90 ·
228
+ > 원안의 "비 RETURNING 방언 throw" 폐기). `count` **처리된 입력수**로 방언
229
+ > 무관 같은 의미다 MySQL upsert 원시 `affectedRows`(삽입 1·갱신 2·무변경 0/1)를
230
+ > 그대로 노출하지 않고 정규화한다. 방언 원시값이 필요하면 `meta`(PG `returnedIds` ·
231
+ > MySQL `firstInsertId`·`affectedRows`)를 쓴다. **행 자체가 필요하면**: 소량은
232
+ > `create()`(단건 · 전 방언 Rec 반환), 대량은 유니크 키로 재조회 — **id 범위 추정
233
+ > 재조회는 금지**(MySQL auto-increment 는 동시 삽입에서 연속 보장이 없다). 삽입 계열은
231
234
  > `create` 처럼 루트 전용 — `where` 필터와 무관하므로 체인 종단이 아니다.
232
235
 
236
+ **개수·집계 반환 타입 — 영역별로 다르다** (결정 90·91 · 헷갈리지 말 것):
237
+
238
+ | 영역 | 반환 타입 | 근거 |
239
+ |---|---|---|
240
+ | 쓰기 개수 (`BulkResult.count` · `updateAll` · `deleteAll`) | `number` | 처리 행 수 — 2^53 초과 실무 없음 · JSON 직렬화 |
241
+ | 읽기 집계 `count()` | `bigint` | E-4 정본 |
242
+ | 읽기 집계 `sum()` · `avg()` | `string` | 무손실(정밀도 보존) |
243
+ | 읽기 집계 `min()` · `max()` | 컬럼 타입 따름 | E-4 정본 |
244
+
245
+ > **왜 sum/avg 가 `number` 가 아닌가**: 양 방언 실측 결과 집계 체인은 이미 방언
246
+ > 일관이라(silent split 없음) 고칠 문제가 없고, `string→number` 강제는 2^53 초과
247
+ > 합계에서 **정밀도를 잃는**(lossy) breaking 이라 "어림 금지" 원칙에 어긋나 기각했다
248
+ > (결정 91). 쓰기 개수(number)와 읽기 집계(string/bigint)는 축이 다르다.
249
+
233
250
  **레코드(`Rec`) 내장** (`model.ts:47-52`):
234
251
 
235
252
  - `rec.update(patch)` — `Partial<Row>` 부분 갱신, 갱신된 Rec 반환.
@@ -546,14 +563,15 @@ const slim = await Post.select(['id', 'title']).all() // Pick<Row, 'id' | 'titl
546
563
  // 삭제 — 단건은 레코드, 벌크는 deleteAll (M2C)
547
564
  const post = await Post.find(id)
548
565
  await post.delete()
549
- const removed = await Post.where('published', '=', false).deleteAll() // bigint
566
+ const removed = await Post.where('published', '=', false).deleteAll() // number
550
567
  const touched = await Post.where('authorId', '=', me.id).updateAll({ published: true })
551
568
 
552
- // 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35)
553
- const seeded = await Post.batchInsert(rows) // 단일 INSERT · Rec[] (순서 보존)
554
- const fresh = await Post.insertOrIgnore(rows) // 충돌 건너뜀 · 실 삽입만
555
- await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
569
+ // 벌크 삽입·UPSERT — 루트 전용 (M2F · 결정 35 → BulkResult 결정 90 · 전 방언 통일)
570
+ const seeded = await Post.batchInsert(rows) // { count } (삽입 )
571
+ const fresh = await Post.insertOrIgnore(rows) // { count } (실 삽입만 · 충돌 건너뜀)
572
+ const { count } = await Post.upsert(rows, { onConflict: 'slug', update: ['title', 'body'] })
556
573
  await Post.upsert({ id, title, body }) // onConflict 생략 = 기본 키(id)
574
+ // 삽입된 행이 필요하면: 소량은 create(), 대량은 유니크 키로 재조회 (id 범위 추정 금지)
557
575
  ```
558
576
 
559
577
  ## 알려진 함정
@@ -568,6 +586,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
568
586
  `foreignKey`/`otherKey` 를 명시한다.
569
587
  - **`updateAll`/`deleteAll` 에 limit·offset·orderBy 가 걸려 있으면 throw** —
570
588
  행을 좁히려면 `pluck('id')` → `whereIn('id', ids)`.
589
+ - **벌크 3종은 행이 아니라 `{ count }` 를 준다** (결정 90) — 반환을 `Rec[]` 처럼
590
+ 다루지 말 것. 삽입된 행이 필요하면 소량은 `create()`, 대량은 유니크 키 재조회.
591
+ `throw` 하던 옛 계약(비 RETURNING 방언)은 폐기 — MySQL 도 그냥 `count` 를 준다.
571
592
  - **loop 안 관계 lazy 호출 = N+1** — doctor **n-plus-one** 검사가 잡는다.
572
593
  목록은 `include()` 로.
573
594
  - **스키마 파일명은 camelCase** — 테이블 `posts_tags` → 파일 `postsTags.ts`
@@ -588,8 +609,9 @@ await Post.upsert({ id, title, body }) // onConflict 생략 = 기
588
609
  | 결정 31 | `rec.delete()` 명명(destroy 아님) · 파라미터 스코프 (M2C) |
589
610
  | 결정 33 | 관계 선언 위치(컬럼 + relations) · 문자열 테이블명 (M2D) |
590
611
  | 결정 34 | 집계·조인 그룹(groupBy·having·distinct·withCount·join) (M2E) |
591
- | 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) |
612
+ | 결정 35 | 벌크 삽입 3종(batchInsert·insertOrIgnore·upsert) (M2F) → **결정 90 으로 개정** |
592
613
  | 결정 39 | 마이그레이션 합성형(파일 replay → 스키마 diff · no-auto-drop) |
614
+ | 결정 90 | 벌크 3종 `BulkResult`(count+meta) 전 방언 통일 · updateAll/deleteAll bigint→number (W14) |
593
615
  | 결정 43 | 네이밍 정본화 · DB 네이밍 SSOT(§1.2 · 테이블 snake · 컬럼 camel) |
594
616
  | 결정 46 | doctor 컬럼(column-casing)·모델/페이지 파일명 검사 3종 |
595
617
  | 결정 47 | `gaon g model` 다단어 테이블명 snake_case(마지막 단어 복수) |
@@ -36,6 +36,12 @@ const props = pageProps<'web:posts#index'>()
36
36
  액션 → `'web:posts#index'`.
37
37
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
38
38
  로 import 하지 않는다.
39
+ - **Gaon 은 `vue-router` 를 쓰지 않는다** — 라우팅은 **Inertia = SPA + 서버
40
+ 라우팅**이라 클라이언트 라우터 라이브러리가 없다. `vue-router`·`react-router`
41
+ 를 install/import 하지 말 것(`createRouter`·`useRoute`·`useRouter`·`<RouterLink>`
42
+ 전부 없음 · CLAUDE.md 규칙 12). 페이지 전환은 `gaonjs/vue` 의 `router`
43
+ (`router.visit(url)`·`router.get/post/delete`), 폼은 `useForm(...)` (결정 64).
44
+ 라우트 정의는 서버의 `apps/<앱>/routes.ts` 뿐이다.
39
45
  - **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
40
46
  금지 (라우트를 모른다는 순수 규칙).
41
47
 
@@ -160,8 +166,13 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
160
166
  이 엔드포인트는 `gaon dev` 에서만 등록되고(운영 빌드엔 없음), 404 면 컴포저블이
161
167
  `available=false` 로 우아하게 degrade 한다.
162
168
 
163
- - **스타일은 SFC `<style scoped>` + CSS 변수** 스캐폴드에 Tailwind 를 넣지
164
- 않는다(의존성 경량 · 결정 69). 유틸 클래스 대신 scoped CSS 캡슐화.
169
+ - **HUB 카드는 introspection(결정 71)** jobs = JetStream 스트림 대기(pending)
170
+ · channels = 프레즌스 KV 권위의 채널별 접속자 수. 조회 실패·미접속을 0/true
171
+ 로 가장하지 않는다(과대 약속 금지) — 버킷·스트림 미생성이면 각각 null 로 degrade.
172
+ - **스타일은 Tailwind 유틸 + UI 킷(결정 74·75)** — 스캐폴드는 Tailwind
173
+ (`tailwind.config.ts`·`postcss.config.js`·`apps/<앱>/style.css`)를 편입한다.
174
+ 랜딩·auth 페이지는 Tailwind 유틸과 UI 킷 컴포넌트(§8)로 그린다(결정 69 의
175
+ scoped-CSS 미편입 방침은 결정 74 로 뒤집혔다).
165
176
  - **auth 링크는 수동(결정 70)** — `gaon g auth` 는 auth 페이지·라우트만 신설하고
166
177
  랜딩·레이아웃 nav 를 편집하지 않는다. 헤더에 로그인 링크를 두려면 `Default.vue`
167
178
  의 nav 에 `<a href="/session/new">로그인</a>` 을 직접 추가한다(Rails 관례).
@@ -169,6 +180,55 @@ import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
169
180
  ·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
170
181
  소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
171
182
 
183
+ ### 8. UI 킷 (결정 74·75 · shadcn 참조 · 복사-소유)
184
+
185
+ `gaon new` 는 기본 앱에 UI 킷을 심고(배터리 포함), `gaon g ui-kit [--app <앱>]`
186
+ 로 다른 앱에도 추가한다. 컴포넌트는 **프로젝트에 복사된 여러분 코드**다 — npm
187
+ 의존이 아니라 파일이므로 자유롭게 고친다(shadcn 방식). 외부 런타임 의존이 없다
188
+ (clsx·tailwind-merge·cva·reka-ui 미도입 · `cn` 은 자작 · Dialog/Sheet 는 Teleport+
189
+ Transition 자작).
190
+
191
+ 배치·import:
192
+
193
+ ```
194
+ apps/<앱>/lib/utils.ts # cn() — 조건부 클래스 병합
195
+ apps/<앱>/components/ui/*.vue # Button·Input·Label·Badge·Card…·Alert…·Form…·Dialog·Sheet
196
+ ```
197
+
198
+ ```vue
199
+ <script setup lang="ts">
200
+ import Button from '../../components/ui/Button.vue' // 앱 로컬 · 상대 경로
201
+ import Card from '../../components/ui/Card.vue'
202
+ import CardContent from '../../components/ui/CardContent.vue'
203
+ </script>
204
+
205
+ <template>
206
+ <Card>
207
+ <CardContent>
208
+ <Button variant="default">저장</Button> <!-- variant·size 로 모양 선택 -->
209
+ </CardContent>
210
+ </Card>
211
+ </template>
212
+ ```
213
+
214
+ - **폼은 UI 킷 Form + gaonjs `useForm`(결정 64)** — `Form` 은 얇은 `<form>` 래퍼로
215
+ `@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
216
+ 않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
217
+ 오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을 표시한다.
218
+ - **`class` 는 폴스루로 병합** — 단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
219
+ 넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
220
+ 자동 해소를 하지 않는다 — 오버라이드가 잦으면 tailwind-merge 를 설치해 `cn` 만 교체.
221
+ - **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
222
+ `text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
223
+ `:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
224
+ - **멀티앱은 앱마다 배선이 따로다(결정 76)** — `gaon g app admin` 은 새 앱에
225
+ 프론트 배선(`apps/admin/{style.css,main.ts,index.html}`)을 web 앱과 동등하게
226
+ 함께 심는다. 그래서 `gaon g ui-kit --app admin` 으로 심은 UI 킷이 Tailwind
227
+ 유틸을 그대로 받는다(`g ui-kit --app` 은 배선이 없으면 멱등으로 보정한다).
228
+ `tailwind.config.ts`·`postcss.config.js` 는 프로젝트 루트 공유(앱마다 두지
229
+ 않는다). 앱이 UI 킷을 import 하는데 그 앱에 `style.css` 배선이 없으면 doctor
230
+ **ui-kit-wiring** 이 경고한다.
231
+
172
232
  ## 정본 예시
173
233
 
174
234
  ```vue
@@ -204,7 +264,20 @@ async function runSearch(q: string) {
204
264
  - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출 금지** — doctor
205
265
  shared-composable-purity 위반. 데이터는 props/인자로.
206
266
  - **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
207
- `Inertia.post()` (`agents/web.md` §4).
267
+ `gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
268
+ - **`vue-router` import 금지** — Gaon 은 클라이언트 라우터가 없다(Inertia =
269
+ SPA + 서버 라우팅). `import { useRouter } from 'vue-router'` 는 존재하지 않는
270
+ 의존을 끌어와 컴파일 실패한다 — 전환은 `gaonjs/vue` 의 `router`, 라우트 정의는
271
+ `apps/<앱>/routes.ts`(§1).
272
+ - **로그아웃은 `router.delete('/session')`** — `?_method=DELETE` 폼 override(Rails/
273
+ Laravel 관례)는 Gaon 에서 안 통한다(Inertia 는 실 DELETE 를 보낸다 · 컴파일은
274
+ 통과해도 런타임 조용히 파손). 로그아웃 링크/버튼은 `router.delete(...)` 또는
275
+ `useForm(...).delete(...)` 로 한다(결정 64 · 절대 규칙 6). `_method` 사용은 doctor
276
+ **method-override** 가 잡는다(결정 89).
277
+ - **채널 구독은 `useChannel()`** — 실시간 클라이언트는 `gaonjs/vue` 의
278
+ `useChannel(name, opts)` 가 정본(결정 87). `new WebSocket` 을 손으로 짜면
279
+ URL(`/gaon/ws/<채널>`)·봉투(`{ t:'msg', data }`)·라이프사이클을 재구현하다
280
+ 틀린다(`agents/realtime.md` §4). 구독 래핑은 컴포저블에.
208
281
  - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상.
209
282
  - **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
210
283
  Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
@@ -221,6 +294,10 @@ async function runSearch(q: string) {
221
294
  | 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
222
295
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
223
296
  | 결정 46 | doctor page-filename(페이지 PascalCase)·model/column 검사 3종 |
224
- | 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 · Tailwind 미편입 · scoped CSS) |
297
+ | 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 · 상태 · 코드 블록) |
225
298
  | 결정 70 | auth 통합 = 수동(`gaon g auth` 는 랜딩·nav 를 안 건드림 · Rails 관례) |
299
+ | 결정 71 | HUB 카드 실 introspection(jobs=스트림 pending · channels=프레즌스 실 접속자 · 과대 약속 금지) |
300
+ | 결정 74 | Tailwind 스캐폴드 편입(tailwind.config.ts·postcss.config.js·style.css · 결정 69 scoped-CSS 방침 뒤집기) |
301
+ | 결정 75 | shadcn 식 UI 킷(`gaon g ui-kit` · 복사-소유 · Vue 3 신작 · 외부 런타임 의존 0) |
302
+ | 결정 76 | 멀티앱 UI 킷 배선 자동화(`g app`·`g ui-kit --app` 이 앱별 Tailwind 배선 동봉·멱등 보정 · doctor ui-kit-wiring) |
226
303
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
@@ -89,29 +89,36 @@ const members = await ctx.presence()
89
89
  - leave 는 best-effort 이고, 서버가 죽으면 허브가 그 서버의 멤버 전원을
90
90
  **즉시** 정리한다 (TCP `close`).
91
91
 
92
- ### 4. 클라이언트 (WebSocket)
92
+ ### 4. 클라이언트 (`useChannel` · 결정 87)
93
93
 
94
- 브라우저는 표준 WebSocket 으로 채널에 접속한다. 세션 앱은 세션 쿠키로,
95
- JWT 앱은 쿼리(`access_token`)로 인증한다.
96
-
97
- **경로는 `<앱 프리픽스>/gaon/ws/<채널명>`** 이고, 보내는 프레임은 `{ t: 'msg',
98
- data }` **봉투** 페이로드를 그대로 보내면 서버가 `onMessage` 로
99
- 흘리지 않는다. 받는 프레임도 같은 모양(`t` 로 종류를 가른다).
94
+ **정본 = `gaonjs/vue` `useChannel(name, opts)`.** 채널 구독의 One Way 다 —
95
+ URL 조립(`<앱 프리픽스>/gaon/ws/<채널명>` · ws/wss 자동)·봉투(`{ t:'msg', data }`)
96
+ 감싸기/풀기·마운트 접속·언마운트 정리·반응형 상태를 한 번에 준다. `new WebSocket`
97
+ 손으로 짜지 것(라이프사이클·봉투를 재구현하다 실수한다). 세션 앱은 쿠키로
98
+ 자동 인증, JWT 앱은 `params: { access_token }`.
100
99
 
101
100
  ```ts
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`)
105
- ws.onmessage = (ev) => {
106
- const frame = JSON.parse(ev.data) // { t: 'msg' | 'presence' | ... , data }
107
- if (frame.t === 'msg') {
108
- // 서버가 broadcast/send 데이터
109
- }
101
+ // apps/web/composables/useRoom.ts — 컴포저블에 래핑(agents/frontend.md §3.2)
102
+ import { useChannel } from 'gaonjs/vue'
103
+
104
+ export function useRoom(roomId: number) {
105
+ // messages(반응형)·status·send·connect·close 돌려준다. 마운트에 접속.
106
+ const { messages, status, send } = useChannel('room', {
107
+ params: { room: roomId },
108
+ onMessage: (data) => { /* 서버가 broadcast/send 한 데이터 */ },
109
+ onPresence: (delta) => { /* 접속자 join/leave */ },
110
+ })
111
+ return { messages, status, send }
110
112
  }
111
- // 보낼 때도 봉투로 감싼다 — 이래야 채널의 onMessage 가 호출된다.
112
- ws.send(JSON.stringify({ t: 'msg', data: { text: '안녕하세요' } }))
113
113
  ```
114
114
 
115
+ - **보내기** — `send(data)` 가 `{ t:'msg', data }` 봉투로 감싸 보낸다(서버 `onMessage`
116
+ 정답 경로). 날 페이로드를 직접 보내면 서버가 안 흘린다.
117
+ - **받기** — `msg` 프레임은 `messages` 에 축적 + `onMessage` 호출, `presence` 는
118
+ `onPresence`. 그 외는 `onFrame`.
119
+ - **탈출구** — 표준 WebSocket 이 필요하면 `new WebSocket('<프리픽스>/gaon/ws/<채널명>')`
120
+ 을 직접 쓸 수 있다(봉투·라이프사이클을 스스로 책임진다). 기본 경로는 `useChannel`.
121
+
115
122
  ### 5. 허브 프로세스 (`gaon hub`)
116
123
 
117
124
  허브는 접속자 목록의 단일 권위이자 서버 간 중계다.
@@ -7,12 +7,21 @@
7
7
 
8
8
  ### 1. 보안 기본값 = fail-closed (v0.15 §2.5.1)
9
9
 
10
- **CORS · rate limit · CSRF 코어에서 기본 켬.** 끄는 것은 명시적
11
- 설정으로만 (v0.15 §2.5.1 원문 근거).
10
+ **CORS · rate limit · CSRF · 보안 응답 헤더는 코어에서 기본 켬.** 끄는 것은
11
+ 명시적 설정으로만 (v0.15 §2.5.1 원문 근거).
12
12
 
13
13
  보안 결함 = 보안이 선택 설치면 설치 안 한 앱의 기본값이 무방비가
14
14
  된다. Rails/Laravel 이 증명한 원칙이다 (§2.5.1).
15
15
 
16
+ - **보안 응답 헤더 (결정 86)** — 코어가 모든 응답에 CSP·HSTS(HTTPS 한정)·
17
+ `X-Content-Type-Options: nosniff`·`X-Frame-Options: SAMEORIGIN`·
18
+ `Referrer-Policy: strict-origin-when-cross-origin`·`Cross-Origin-Opener-Policy`
19
+ 를 자동으로 붙인다. 기본 CSP 는 Inertia SPA + vite 스택에서 안 깨지게 튜닝돼
20
+ 있다(`script-src 'self'`·인라인 스타일 허용·`connect-src ... ws: wss:` 로
21
+ realtime 허용). 끄거나 조정은 `createApp({ security: { securityHeaders: … } })`
22
+ — `false` 로 전부 끔, `{ contentSecurityPolicy: '…' | false, hsts: false }` 로 조정.
23
+ `helmet` 등 라이브러리를 따로 깔지 말 것(코어 내장 · 라이브러리 미의존).
24
+
16
25
  ### 2. 세션·CSRF·JWT
17
26
 
18
27
  - 세션은 앱별 완전 분리 (v0.15 §7 · Fastify 캡슐화 스코프): 쿠키
@@ -275,6 +275,11 @@ export default controller({
275
275
  - **`@gaonjs/*` 스코프 직접 import 금지** — 파사드 `gaonjs/*` 만.
276
276
  - **bigint PK 를 render props 로 흘릴 때는 `String(p.id)` 정규화**
277
277
  (결정 37 · 상세는 `agents/frontend.md`).
278
+ - **정적 파일(robots.txt·favicon.ico·이미지 등)은 `apps/<앱>/static/`** 에 둔다
279
+ (결정 85) — 앱 prefix 아래로 서빙된다(web→`/robots.txt`, admin→`/admin/robots.txt`).
280
+ 라우트·`/assets/*` 가 항상 우선하므로 라우트와 같은 경로에 두면 가려진다
281
+ (doctor **static-collision** 가 경고). 정적 서빙에 `@fastify/static` 을 따로
282
+ 깔지 말 것(코어 내장).
278
283
 
279
284
  ## 관련 결정 번호
280
285
 
@@ -28,7 +28,9 @@ export interface HealthDatabase {
28
28
  export interface HealthHub {
29
29
  configured: boolean
30
30
  connected?: boolean
31
+ // jobs: JetStream 스트림 대기 수 · channels: 채널별 실 접속자 수(결정 71).
31
32
  streams?: { stream: string; waiting: number | null }[]
33
+ channels?: { channel: string; members: number }[] | null
32
34
  error?: string
33
35
  }
34
36
  export interface HealthDoctor {
@@ -6,114 +6,39 @@
6
6
  // 레이아웃은 shared 에 두지 않는다(E-5 §2.3) — 앱마다 레이아웃이 다른 것이
7
7
  // 정상이고, 공용 조각(로고·푸터 등) 만 shared/components 로 뽑는다.
8
8
  //
9
- // 다크 헤더 + 라이트 본문(결정 69). 버전은 스캐폴드 시점 gaonjs 버전이 박힌다.
10
- //
11
- // 결정 70(auth = 수동): gaon g auth 는 이 nav 를 건드리지 않는다. 로그인 링크를
12
- // 헤더에 두려면 아래 nav 에 <a href="/session/new">로그인</a> 을 직접 추가한다.
9
+ // 다크 헤더 + 라이트 본문(결정 69). 스타일은 Tailwind 유틸(결정 74) 다크
10
+ // 헤더는 본문 테마와 무관하게 항상 어둡게 두려고 브랜드 색을 명시값으로 박는다.
11
+ // 버전은 스캐폴드 시점 gaonjs 버전이 박힌다.
13
12
 
14
13
  // package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
15
14
  const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
16
15
  </script>
17
16
 
18
17
  <template>
19
- <div class="gaon-layout">
20
- <header class="gaon-header">
21
- <a class="gaon-brand" href="/">
22
- <span class="gaon-brand-mark">가온</span>
23
- <span class="gaon-brand-word">GAONJS</span>
24
- <span class="gaon-brand-ver">v{{ version }}</span>
18
+ <div class="flex min-h-screen flex-col bg-background text-foreground">
19
+ <header
20
+ class="flex items-center justify-between gap-4 border-b border-[#21262d] bg-[#0d1117] px-6 py-3.5 text-[#e6edf3]"
21
+ >
22
+ <a href="/" class="inline-flex items-baseline gap-2.5 text-inherit no-underline">
23
+ <span
24
+ class="inline-block rounded-md bg-gradient-to-br from-[#4f8cff] to-[#7c5cff] px-1.5 py-0.5 text-xs font-bold tracking-wide text-white"
25
+ >가온</span>
26
+ <span class="text-[0.95rem] font-bold tracking-[0.08em]">GAONJS</span>
27
+ <span class="text-xs tabular-nums text-[#8b949e]">v{{ version }}</span>
25
28
  </a>
26
- <nav class="gaon-nav">
27
- <a href="https://gaonjs.dev" target="_blank" rel="noreferrer">문서</a>
28
- <a href="https://github.com/gaonjs" target="_blank" rel="noreferrer">GitHub</a>
29
+ <nav class="flex gap-[1.1rem] text-sm">
30
+ <a href="https://gaonjs.dev" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">문서</a>
31
+ <a href="https://github.com/gaonjs" target="_blank" rel="noreferrer" class="text-[#c9d1d9] no-underline hover:text-white">GitHub</a>
29
32
  </nav>
30
33
  </header>
31
34
 
32
- <main class="gaon-main">
35
+ <main class="w-full flex-1">
33
36
  <slot />
34
37
  </main>
35
38
 
36
- <footer class="gaon-footer">
39
+ <footer class="border-t border-border px-6 py-5 text-center text-muted-foreground">
37
40
  <small>{{PROJECT_NAME}} · Powered by
38
- <a href="https://gaonjs.dev" target="_blank" rel="noreferrer">Gaon</a></small>
41
+ <a href="https://gaonjs.dev" target="_blank" rel="noreferrer" class="text-[#4f8cff] no-underline">Gaon</a></small>
39
42
  </footer>
40
43
  </div>
41
44
  </template>
42
-
43
- <style scoped>
44
- .gaon-layout {
45
- min-height: 100vh;
46
- display: flex;
47
- flex-direction: column;
48
- background: #ffffff;
49
- color: #1b1f24;
50
- font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
51
- }
52
-
53
- /* 다크 헤더 — 본문 테마와 무관하게 브랜드 바는 어둡게. */
54
- .gaon-header {
55
- display: flex;
56
- align-items: center;
57
- justify-content: space-between;
58
- gap: 1rem;
59
- padding: 0.85rem 1.5rem;
60
- background: #0d1117;
61
- color: #e6edf3;
62
- border-bottom: 1px solid #21262d;
63
- }
64
- .gaon-brand {
65
- display: inline-flex;
66
- align-items: baseline;
67
- gap: 0.55rem;
68
- text-decoration: none;
69
- color: inherit;
70
- }
71
- .gaon-brand-mark {
72
- display: inline-block;
73
- padding: 0.15rem 0.45rem;
74
- border-radius: 6px;
75
- background: linear-gradient(135deg, #4f8cff, #7c5cff);
76
- color: #fff;
77
- font-weight: 700;
78
- font-size: 0.8rem;
79
- letter-spacing: 0.02em;
80
- }
81
- .gaon-brand-word {
82
- font-weight: 700;
83
- letter-spacing: 0.08em;
84
- font-size: 0.95rem;
85
- }
86
- .gaon-brand-ver {
87
- font-size: 0.72rem;
88
- color: #8b949e;
89
- font-variant-numeric: tabular-nums;
90
- }
91
- .gaon-nav {
92
- display: flex;
93
- gap: 1.1rem;
94
- font-size: 0.85rem;
95
- }
96
- .gaon-nav a {
97
- color: #c9d1d9;
98
- text-decoration: none;
99
- }
100
- .gaon-nav a:hover {
101
- color: #fff;
102
- }
103
-
104
- .gaon-main {
105
- flex: 1;
106
- width: 100%;
107
- }
108
-
109
- .gaon-footer {
110
- padding: 1.25rem 1.5rem;
111
- text-align: center;
112
- color: #6a737d;
113
- border-top: 1px solid #eaecef;
114
- }
115
- .gaon-footer a {
116
- color: #4f8cff;
117
- text-decoration: none;
118
- }
119
- </style>
@@ -9,6 +9,10 @@
9
9
  // import.meta.glob 으로 만든다. 심볼 출처가 코드에 그대로 보인다.
10
10
  import { createGaonApp } from 'gaonjs/vue'
11
11
 
12
+ // 전역 스타일 — Tailwind 레이어 + 디자인 토큰(결정 74). 부수효과 import 라
13
+ // 번들에 CSS 가 실린다. 앱마다 하나(관례 = 배치).
14
+ import './style.css'
15
+
12
16
  // 페이지는 지연 로드(코드 스플리팅) — 큰 앱에서도 첫 페이지 로딩이 빠르다.
13
17
  // eager 로 바꿔도 되지만, One Way 의 기본은 지연 로드다.
14
18
  const pages = import.meta.glob('./pages/**/*.vue')