@gaonjs/cli 0.4.0 → 0.10.0

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 (120) hide show
  1. package/dist/commands/check.d.ts +50 -0
  2. package/dist/commands/check.js +286 -0
  3. package/dist/commands/console.d.ts +46 -0
  4. package/dist/commands/console.js +129 -0
  5. package/dist/commands/db.d.ts +3 -1
  6. package/dist/commands/db.js +8 -2
  7. package/dist/commands/g.d.ts +1 -1
  8. package/dist/commands/g.js +27 -3
  9. package/dist/commands/mcp.d.ts +15 -0
  10. package/dist/commands/mcp.js +78 -0
  11. package/dist/commands/new.d.ts +45 -0
  12. package/dist/commands/new.js +274 -0
  13. package/dist/commands/test.d.ts +11 -0
  14. package/dist/commands/test.js +119 -0
  15. package/dist/db/diff.js +5 -0
  16. package/dist/db/journal.d.ts +34 -0
  17. package/dist/db/journal.js +71 -0
  18. package/dist/db/migrate.d.ts +6 -1
  19. package/dist/db/migrate.js +120 -102
  20. package/dist/db/replay.d.ts +49 -0
  21. package/dist/db/replay.js +148 -0
  22. package/dist/db/status.d.ts +12 -0
  23. package/dist/db/status.js +61 -0
  24. package/dist/dev/index.d.ts +2 -0
  25. package/dist/dev/index.js +2 -0
  26. package/dist/dev/vite.d.ts +67 -0
  27. package/dist/dev/vite.js +126 -0
  28. package/dist/dev.d.ts +18 -0
  29. package/dist/dev.js +15 -0
  30. package/dist/doctor/agents-doc-index.d.ts +4 -0
  31. package/dist/doctor/agents-doc-index.js +80 -0
  32. package/dist/doctor/fixers/dependency-direction.d.ts +9 -0
  33. package/dist/doctor/fixers/dependency-direction.js +98 -0
  34. package/dist/doctor/fixers/index.d.ts +15 -0
  35. package/dist/doctor/fixers/index.js +66 -0
  36. package/dist/doctor/fixers/schema-filename.d.ts +14 -0
  37. package/dist/doctor/fixers/schema-filename.js +104 -0
  38. package/dist/doctor/fixers/types.d.ts +59 -0
  39. package/dist/doctor/fixers/types.js +15 -0
  40. package/dist/doctor/no-auto-import.d.ts +10 -0
  41. package/dist/doctor/no-auto-import.js +158 -0
  42. package/dist/doctor/schema-filename.d.ts +6 -0
  43. package/dist/doctor/schema-filename.js +81 -0
  44. package/dist/doctor/shared-composable-purity.d.ts +8 -0
  45. package/dist/doctor/shared-composable-purity.js +164 -0
  46. package/dist/doctor/types.d.ts +1 -1
  47. package/dist/doctor/types.js +6 -5
  48. package/dist/doctor.d.ts +51 -0
  49. package/dist/doctor.js +191 -7
  50. package/dist/generate.js +2 -2
  51. package/dist/hub.d.ts +1 -1
  52. package/dist/index.d.ts +6 -2
  53. package/dist/index.js +154 -16
  54. package/dist/mcp/index.d.ts +7 -0
  55. package/dist/mcp/index.js +7 -0
  56. package/dist/mcp/server.d.ts +50 -0
  57. package/dist/mcp/server.js +102 -0
  58. package/dist/mcp/tools.d.ts +109 -0
  59. package/dist/mcp/tools.js +485 -0
  60. package/dist/scaffold/app.d.ts +5 -0
  61. package/dist/scaffold/app.js +172 -0
  62. package/dist/scaffold/controller.js +2 -2
  63. package/dist/scaffold/index.d.ts +2 -1
  64. package/dist/scaffold/index.js +2 -1
  65. package/dist/scaffold/job.d.ts +5 -0
  66. package/dist/scaffold/job.js +35 -0
  67. package/dist/scaffold/model.js +8 -8
  68. package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
  69. package/dist/templates/auth/registration.controller.ts.tpl +1 -1
  70. package/dist/templates/auth/session.controller.ts.tpl +1 -1
  71. package/dist/templates/auth/user.model.ts.tpl +1 -1
  72. package/dist/templates/index.d.ts +23 -0
  73. package/dist/templates/index.js +66 -0
  74. package/dist/templates/index.ts +85 -0
  75. package/dist/templates/project/.env.example.tpl +18 -0
  76. package/dist/templates/project/.gitignore.tpl +24 -0
  77. package/dist/templates/project/.npmrc.tpl +4 -0
  78. package/dist/templates/project/AGENTS.md.tpl +210 -0
  79. package/dist/templates/project/CLAUDE.md.tpl +119 -0
  80. package/dist/templates/project/agents/async.md.tpl +218 -0
  81. package/dist/templates/project/agents/data.md.tpl +532 -0
  82. package/dist/templates/project/agents/frontend.md.tpl +201 -0
  83. package/dist/templates/project/agents/realtime.md.tpl +157 -0
  84. package/dist/templates/project/agents/security.md.tpl +92 -0
  85. package/dist/templates/project/agents/testing.md.tpl +101 -0
  86. package/dist/templates/project/agents/web.md.tpl +177 -0
  87. package/dist/templates/project/apps/web/channels/.gitkeep.tpl +1 -0
  88. package/dist/templates/project/apps/web/components/.gitkeep.tpl +1 -0
  89. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +25 -0
  90. package/dist/templates/project/apps/web/controllers/home.ts.tpl +19 -0
  91. package/dist/templates/project/apps/web/index.html.tpl +18 -0
  92. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +43 -0
  93. package/dist/templates/project/apps/web/main.ts.tpl +24 -0
  94. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +36 -0
  95. package/dist/templates/project/apps/web/routes.ts.tpl +8 -0
  96. package/dist/templates/project/docker-compose.yaml.tpl +73 -0
  97. package/dist/templates/project/domain/events/.gitkeep.tpl +1 -0
  98. package/dist/templates/project/domain/jobs/.gitkeep.tpl +1 -0
  99. package/dist/templates/project/domain/listeners/.gitkeep.tpl +1 -0
  100. package/dist/templates/project/domain/mails/.gitkeep.tpl +1 -0
  101. package/dist/templates/project/domain/models/.gitkeep.tpl +1 -0
  102. package/dist/templates/project/domain/schema/.gitkeep.tpl +1 -0
  103. package/dist/templates/project/domain/services/.gitkeep.tpl +1 -0
  104. package/dist/templates/project/gaon.config.ts.tpl +27 -0
  105. package/dist/templates/project/package.json.tpl +30 -0
  106. package/dist/templates/project/pnpm-workspace.yaml.tpl +11 -0
  107. package/dist/templates/project/shared/components/.gitkeep.tpl +1 -0
  108. package/dist/templates/project/shared/composables/useDebounce.ts.tpl +21 -0
  109. package/dist/templates/project/tsconfig.json.tpl +25 -0
  110. package/dist/templates/project/vite.config.ts.tpl +23 -0
  111. package/dist/tsResolve.js +1 -1
  112. package/dist/work.d.ts +2 -2
  113. package/dist/work.js +3 -1
  114. package/package.json +13 -11
  115. package/dist/__fixtures__/db-minimal/domain/schema/widgets.d.ts +0 -12
  116. package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +0 -7
  117. package/dist/__fixtures__/db-minimal/gaon.config.d.ts +0 -2
  118. package/dist/__fixtures__/db-minimal/gaon.config.js +0 -11
  119. package/dist/check.d.ts +0 -29
  120. package/dist/check.js +0 -92
@@ -0,0 +1,177 @@
1
+ # agents/web.md — 웹 레이어 (라우팅 · 컨트롤러 · params · JSON 액션 · 인증)
2
+
3
+ > 골격: **정본 규칙 → 정본 예시 → 알려진 함정 → 관련 결정 번호** (결정 40 · 2층 구조).
4
+ > 루트 `AGENTS.md` 는 코어 요약만 담는다 — 시그니처·규칙 상세의 정본은 이 파일이다.
5
+ > 대상 패키지: `@gaonjs/web` (파사드 import 는 `gaonjs/web`).
6
+
7
+ ## 정본 규칙
8
+
9
+ ### 1. JSON 액션 — 반환값이 곧 응답 (errata E-3 §3.1)
10
+
11
+ 컨트롤러 액션이 `this.render(...)` 나 `this.redirect(...)` 대신
12
+ **평범한 객체/배열을 반환하면 그것이 곧 JSON 응답**이다. 별도
13
+ 데코레이터·설정·헬퍼 호출이 없다 (E-3 §3.1 원문).
14
+
15
+ ```ts
16
+ // apps/web/controllers/posts.ts
17
+ import { controller } from 'gaonjs/web' // 파사드 · 함수형 API
18
+ import { Post } from '../../../domain/models/Post.js' // 앱→도메인 (rule 5)
19
+
20
+ export default controller({
21
+ async index() {
22
+ return this.render('Posts/Index', { posts: await Post.latest().limit(20).all() })
23
+ },
24
+
25
+ // JSON 액션 — 객체를 반환하면 JSON 응답
26
+ async search() {
27
+ const { q } = this.params(Post.searchForm) // 검증 실패 시 자동 422 JSON
28
+ return { results: await Post.published().search(q).limit(10).all() }
29
+ },
30
+ })
31
+ ```
32
+
33
+ - **import 관례** — 프레임웍 심볼은 파사드 `gaonjs/*` 에서 (`gaonjs/web`·
34
+ `gaonjs/data`·`gaonjs/vue`·`gaonjs/async` 등), 도메인 모델은 상대경로
35
+ `../../../domain/models/<Pascal>.js` 로 참조한다. `@gaonjs/*` (스코프
36
+ 이름) 은 내부 패키지 이름 — 앱 코드에서 직접 import 하지 않는다.
37
+ `@inertiajs/vue3` 는 어댑터 내부 의존 — 앱에서 직접 안 쓴다.
38
+ - **`this.render` 인자** = `<PageFolder>/<Page>` (PascalCase 폴더 · Vue
39
+ 파일명과 정합) — 예: `'Posts/Index'` → `apps/<app>/pages/Posts/Index.vue`.
40
+ - **리소스 부재 = `this.notFound()`** — 조회 결과가 없으면 404 를 손으로 만들지
41
+ 말고 `this.notFound()` 로 마감한다(`requireAuth()` 와 동형). `never` 를 반환해
42
+ 이 뒤로 값이 존재하는 것으로 좁혀지므로, `render` 반환 타입(타입 브리지)도 그대로
43
+ 유지된다. 메시지는 선택: `this.notFound('post 없음')`.
44
+
45
+ ```ts
46
+ async show() {
47
+ const post = await Post.find(this.params(Post.showForm).id)
48
+ if (!post) return this.notFound() // 404 · 이 뒤로 post 는 non-null
49
+ return this.render('Posts/Show', { post })
50
+ }
51
+ ```
52
+
53
+ ### 2. 응답 규칙 — 한 액션은 한 종류 응답만
54
+
55
+ - **한 액션은 한 종류 응답만 낸다** (render 또는 JSON 또는 redirect —
56
+ 조건 분기 혼용 금지). `gaon doctor` 의 **response-mixing** 검사가
57
+ 혼용을 잡는다.
58
+ - 검증은 페이지 액션과 동일하게 `this.params(스키마)` — 실패 시
59
+ 422 JSON.
60
+ - 인증·세션은 같은 앱의 세션 쿠키를 그대로 쓴다. CSRF·rate limit 등
61
+ 보안 기본값도 그대로 적용된다 (`agents/security.md` · 끄는 것은
62
+ 명시적 설정으로만).
63
+
64
+ ### 3. `this.params` 통합 입력 · 안전 규칙 (errata E-3 §5 · 결정 24)
65
+
66
+ `this.params` 는 라우트 파라미터·query·body(업로드 파일 포함)를
67
+ 하나로 합쳐서 주는 Rails 식 통합 입력이다. 안전을 위해 아래 세 규칙이
68
+ 반드시 함께 적용된다.
69
+
70
+ **출처 우선순위 — 고정, 설정 불가:**
71
+
72
+ - **라우트 파라미터 > body > query** 순서로 병합 (E-3 §5.1 원문).
73
+ - 라우트 파라미터가 절대 덮어써지지 않는 것이 핵심 — `/posts/:id` 의
74
+ `id` 를 공격자가 body 의 `id: 999` 로 바꿔치기해서 권한 검사를
75
+ 우회하는 파라미터 오염 공격을 원천 차단한다.
76
+ - 이 우선순위는 문서화된 고정 규칙이며 **설정으로 바꿀 수 없다**.
77
+
78
+ **중복 키:**
79
+
80
+ - `?tag=a&tag=b` 처럼 같은 출처에서 키가 중복되면: 스키마의 해당
81
+ 필드가 배열 타입이면 배열로 수집, 아니면 **마지막 값**을 쓴다
82
+ (E-3 §5.2 원문).
83
+
84
+ **출처 명시 탈출구 — `this.body()` / `this.query()`:**
85
+
86
+ - 출처 자체가 의미를 갖는 드문 경우(웹훅 수신처럼 반드시 body에서만
87
+ 와야 하는 데이터, 서명 검증 대상 등)에만 쓴다.
88
+ - 기본 경로는 여전히 `this.params` 하나(The One Way) — 스캐폴드·문서·
89
+ 기본 예시는 `this.params` 만 쓴다 (E-3 §5.3 원문).
90
+
91
+ ### 4. 데이터 경로 판단 — 루트 판단표가 정본
92
+
93
+ 데이터가 필요할 때는 루트 `AGENTS.md` 의 데이터 경로 4종 판단표를 따른다
94
+ (Inertia partial reload · 채널/프레즌스 · JSON 액션 + `api()` · 별도 API 앱 + JWT).
95
+ **Inertia = SPA + 서버 라우팅**이지 SSR 이 아니다. 로그인·회원가입도 컨트롤러
96
+ `this.render('auth/Login')` + Vue 페이지 `Inertia.post()` → 서버
97
+ redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드포인트도
98
+ 없다. REST + `fetch()` 는 **API 앱(JWT) 전용**.
99
+
100
+ ### 5. 비밀번호 해싱 — `hashPassword` · `verifyPassword` (`gaonjs/web`)
101
+
102
+ 회원가입·로그인에서 비밀번호를 다룰 때는 **직접 crypto/bcrypt 를 import 하거나
103
+ base64·해시를 손으로 짜지 말고** `gaonjs/web` 의 헬퍼를 쓴다(The One Way · §7 인증).
104
+ bcrypt(cost 10)로 해싱하며 상수시간 비교를 제공한다.
105
+
106
+ ```ts
107
+ import { hashPassword, verifyPassword } from 'gaonjs/web'
108
+
109
+ // 가입 — 서비스에서 평문을 해싱해 hidden 컬럼(passwordDigest)에 저장.
110
+ const passwordDigest = await hashPassword(plain) // Promise<string>
111
+ const user = await User.create({ name, email, passwordDigest })
112
+
113
+ // 로그인 — 저장된 다이제스트와 평문을 비교(상수시간).
114
+ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
115
+ ```
116
+
117
+ - **시그니처** — `hashPassword(plain: string): Promise<string>` ·
118
+ `verifyPassword(plain: string, digest: string): Promise<boolean>`. 둘 다 async.
119
+ - **기본값** — bcrypt cost 10(보안/지연 균형점). 순수 JS(`bcryptjs`)라 네이티브
120
+ 빌드 없이 어느 환경에서나 설치가 확실하다.
121
+ - **저장 위치** — 결과는 스키마의 hidden 컬럼(`passwordDigest: t.string().hidden()`)
122
+ 에 담는다 — 응답 경계에서 타입·런타임 양쪽으로 페이지 노출이 막힌다.
123
+ - 회원 생성 로직은 컨트롤러가 아니라 **서비스**(`agents/data.md` §9 · registerUser)에
124
+ 둔다 — `const user = await RegisterUser.call({ name, email, password })`.
125
+
126
+ ### 6. 인증·세션 (v0.15 §7 · M5)
127
+
128
+ - 세션 쿠키가 기본, JWT 는 **API 앱 전용 옵션** (v0.11 확정).
129
+ - 세션은 앱별 완전 분리 (Fastify 캡슐화 스코프): 쿠키 이름(`<app>_sid`) ·
130
+ 서명 secret · Redis 키 prefix · 쿠키 path 가 앱 단위로 갇힌다.
131
+ - 스캐폴드는 `gaon g auth` — 로그인/회원가입 컨트롤러·페이지·라우트 일습.
132
+ - 로그인 필요 액션은 `this.requireAuth()` 관례 (notFound 와 동형 — never 좁힘).
133
+
134
+ ## 정본 예시
135
+
136
+ ```ts
137
+ // apps/web/controllers/registration.ts — 회원 가입 (E-3 · Inertia SPA)
138
+ import { controller } from 'gaonjs/web'
139
+ import { RegisterUser } from '../../../domain/services/registerUser.js'
140
+ import { SendWelcomeMail } from '../../../domain/jobs/sendWelcomeMail.js'
141
+
142
+ export default controller({
143
+ async new() {
144
+ return this.render('Auth/Register', {})
145
+ },
146
+ async create() {
147
+ const user = await RegisterUser.call(this.params(RegisterUser.form))
148
+ await SendWelcomeMail.later(user.id) // 잡 발행 위치는 결정 32 — 서비스 afterCommit 도 정합
149
+ return this.redirect('/dashboard')
150
+ },
151
+ })
152
+ ```
153
+
154
+ ## 알려진 함정
155
+
156
+ - **render/JSON/redirect 를 한 액션에서 조건 혼용하면 doctor
157
+ response-mixing 위반** — 액션을 나눈다.
158
+ - **`fetch()` 로 로그인 폼 구현 금지** — 세션 앱 폼은 `Inertia.post()`.
159
+ REST + fetch 는 API 앱(JWT) 전용.
160
+ - **컨트롤러에 비즈니스 로직 인라인 금지** (§5.3 One Way) — 여러 모델·
161
+ 트랜잭션·외부 API 가 얽히면 `domain/services/`.
162
+ - **컨트롤러에서 메일·외부 발송 직접 호출 금지** — 잡 발행만
163
+ (`agents/async.md`). nodemailer·resend 등 SDK 직접 import 는 함정.
164
+ - **bcrypt·crypto 직접 import 금지** — `hashPassword`/`verifyPassword`.
165
+ - **`@gaonjs/*` 스코프 직접 import 금지** — 파사드 `gaonjs/*` 만.
166
+ - **bigint PK 를 render props 로 흘릴 때는 `String(p.id)` 정규화**
167
+ (결정 37 · 상세는 `agents/frontend.md`).
168
+
169
+ ## 관련 결정 번호
170
+
171
+ | 결정 | 내용 |
172
+ |---|---|
173
+ | 결정 23 (E-3) | 앱 내 JSON 액션 — 반환값 = 응답 |
174
+ | 결정 24 (E-3 §5) | `this.params` 안전 규칙 (라우트 > body > query · 중복 키 · body/query 탈출구) |
175
+ | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 — `agents/async.md`) |
176
+ | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
177
+ | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1,25 @@
1
+ // useApiPing — 앱 전용 컴포저블(errata E-5 §2.1).
2
+ // 파일 하나에 컴포저블 하나 · 파일명 = 컴포저블명 · use 접두사.
3
+ // 앱 전용 컴포저블은 api()/pageProps() 를 자유롭게 쓸 수 있다.
4
+ // shared/composables 는 반대 — 인자로만 받는 순수 로직(E-5 §2.2).
5
+ import { ref } from 'vue'
6
+
7
+ /** GET /health 를 두드려 서버가 살아 있는지 확인하는 예시 컴포저블. */
8
+ export function useApiPing() {
9
+ const ok = ref<boolean | null>(null)
10
+ const error = ref<string | null>(null)
11
+
12
+ async function ping(): Promise<void> {
13
+ try {
14
+ const res = await fetch('/health', { headers: { Accept: 'application/json' } })
15
+ const body = (await res.json()) as { ok: boolean }
16
+ ok.value = body.ok === true
17
+ error.value = null
18
+ } catch (err) {
19
+ ok.value = false
20
+ error.value = err instanceof Error ? err.message : String(err)
21
+ }
22
+ }
23
+
24
+ return { ok, error, ping }
25
+ }
@@ -0,0 +1,19 @@
1
+ // home 컨트롤러 — apps/web/controllers/home.ts. 페이지 액션(this.render)과
2
+ // JSON 액션(반환값 = 응답 · errata E-3)의 두 대표 패턴을 함께 담는다.
3
+ import { controller } from 'gaonjs/web'
4
+
5
+ export default controller({
6
+ // GET / — Inertia SPA 홈. this.render 는 Vue 페이지(Home/Index.vue) 로 넘긴다.
7
+ async index() {
8
+ return this.render('Home/Index', {
9
+ title: '{{PROJECT_NAME}}',
10
+ docs: 'https://gaonjs.dev',
11
+ })
12
+ },
13
+
14
+ // GET /health — JSON 액션(errata E-3). 반환값이 곧 응답.
15
+ // 배포 후 헬스체크·60초 실측(v0.15 §13.5 M9 완료 기준)에 쓰인다.
16
+ async health() {
17
+ return { ok: true, service: '{{PROJECT_NAME}}' }
18
+ },
19
+ })
@@ -0,0 +1,18 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>{{PROJECT_NAME}}</title>
7
+ </head>
8
+ <body>
9
+ <!--
10
+ Vite 개발 서버가 이 index.html 을 서빙한다(dev). 운영 빌드는
11
+ vite build 가 이 파일을 진입점 삼아 프로덕션 번들을 만든다.
12
+ Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 아래와 같은 구조의
13
+ <div id="app" data-page="..."> 를 내려보낸다 — 개발과 운영이 같은 셸.
14
+ -->
15
+ <div id="app"></div>
16
+ <script type="module" src="/main.ts"></script>
17
+ </body>
18
+ </html>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ // 기본 레이아웃 — apps/web/layouts/Default.vue (errata E-5 §2.3).
3
+ // 파일이 존재하면 이 앱의 모든 페이지에 자동 적용된다(파일 존재 = 등록).
4
+ // 다른 레이아웃이 필요한 페이지만 페이지 파일에서 명시적으로 바꾼다.
5
+ //
6
+ // 레이아웃은 shared 에 두지 않는다(E-5 §2.3) — 앱마다 레이아웃이 다른 것이
7
+ // 정상이고, 공용 조각(로고·푸터 등) 만 shared/components 로 뽑는다.
8
+ </script>
9
+
10
+ <template>
11
+ <div class="layout">
12
+ <header class="layout-header">
13
+ <strong>{{PROJECT_NAME}}</strong>
14
+ </header>
15
+ <slot />
16
+ <footer class="layout-footer">
17
+ <small>Powered by <a href="https://gaonjs.dev" target="_blank">Gaon</a></small>
18
+ </footer>
19
+ </div>
20
+ </template>
21
+
22
+ <style scoped>
23
+ .layout {
24
+ min-height: 100vh;
25
+ display: flex;
26
+ flex-direction: column;
27
+ }
28
+ .layout-header,
29
+ .layout-footer {
30
+ padding: 1rem;
31
+ background: #f6f8fa;
32
+ border-color: #e1e4e8;
33
+ }
34
+ .layout-header {
35
+ border-bottom: 1px solid #e1e4e8;
36
+ }
37
+ .layout-footer {
38
+ border-top: 1px solid #e1e4e8;
39
+ margin-top: auto;
40
+ text-align: center;
41
+ color: #6a737d;
42
+ }
43
+ </style>
@@ -0,0 +1,24 @@
1
+ // apps/web/main.ts — 프론트엔드 진입 (v0.16 §6.4 · M3 최소 웹 레이어).
2
+ //
3
+ // 브라우저 부팅 흐름:
4
+ // 1) index.html 이 이 파일을 <script type="module"> 로 로드한다.
5
+ // 2) createGaonApp 이 Inertia SPA 를 마운트한다.
6
+ // 3) 서버 응답의 data-page 를 어댑터가 읽어 첫 페이지를 그린다.
7
+ //
8
+ // 자동 import 금지(E-5 §2.4) — 페이지·레이아웃 지도는 아래처럼 명시적으로
9
+ // import.meta.glob 으로 만든다. 심볼 출처가 코드에 그대로 보인다.
10
+ import { createGaonApp } from 'gaonjs/vue'
11
+
12
+ // 페이지는 지연 로드(코드 스플리팅) — 큰 앱에서도 첫 페이지 로딩이 빠르다.
13
+ // eager 로 바꿔도 되지만, One Way 의 기본은 지연 로드다.
14
+ const pages = import.meta.glob('./pages/**/*.vue')
15
+
16
+ // 레이아웃은 eager — 소수·최초 진입에도 필요하므로 굳이 지연 로드하지 않는다.
17
+ // layouts/Default.vue 가 있으면 모든 페이지에 자동 적용된다(errata E-5 §2.3).
18
+ const layouts = import.meta.glob('./layouts/*.vue', { eager: true })
19
+
20
+ void createGaonApp({
21
+ pages,
22
+ layouts,
23
+ title: (t) => (t ? `${t} · {{PROJECT_NAME}}` : '{{PROJECT_NAME}}'),
24
+ })
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import { pageProps } from 'gaonjs/vue'
3
+
4
+ // home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
5
+ // 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
6
+ const props = pageProps<'home#index'>()
7
+ </script>
8
+
9
+ <template>
10
+ <main class="home">
11
+ <h1>{{ props.title }}</h1>
12
+ <p>Gaon 프레임웍이 방금 이 앱을 만들었습니다.</p>
13
+ <p>
14
+ 문서: <a :href="props.docs" target="_blank">{{ props.docs }}</a>
15
+ </p>
16
+ <hr />
17
+ <h2>다음 단계</h2>
18
+ <ol>
19
+ <li><code>gaon g auth</code> — 인증 스캐폴드 생성 (회원가입·로그인·세션)</li>
20
+ <li><code>gaon g model Post</code> — 모델 스캐폴드 (스키마 + 모델 · E-4)</li>
21
+ <li><code>gaon g controller posts</code> — 컨트롤러 스캐폴드</li>
22
+ <li><code>gaon g page Posts/Index</code> — Vue 페이지 (Inertia SPA)</li>
23
+ <li><code>gaon dev</code> — Docker 자동 기동 · 타입 브리지 · watch</li>
24
+ </ol>
25
+ </main>
26
+ </template>
27
+
28
+ <style scoped>
29
+ .home {
30
+ max-width: 640px;
31
+ margin: 4rem auto;
32
+ padding: 0 1rem;
33
+ font-family: system-ui, -apple-system, sans-serif;
34
+ line-height: 1.6;
35
+ }
36
+ </style>
@@ -0,0 +1,8 @@
1
+ // web 앱 라우트 — apps/web/routes.ts. 앱 폴더명(web)이 URL 프리픽스가 되지만
2
+ // web 앱은 관례상 프리픽스 '/' 를 쓴다(v0.15 §6.1).
3
+ import { routes } from 'gaonjs/web'
4
+
5
+ export default routes((r) => {
6
+ r.get('/', 'home#index') // GET / → home#index (Inertia SPA · Home/Index.vue)
7
+ r.get('/health', 'home#health') // GET /health → JSON { ok: true } (헬스체크)
8
+ })
@@ -0,0 +1,73 @@
1
+ # {{PROJECT_NAME}} 개발 스택 — gaon dev 가 자동 기동한다(CLAUDE.md §2 · v0.15 §9).
2
+ # 목업·인메모리 대체는 금지 — 개발·테스트·운영 모두 실 인프라를 쓴다.
3
+ #
4
+ # 기동: docker compose up -d (gaon dev 가 자동 실행)
5
+ # 정지: docker compose down
6
+ name: {{PROJECT_NAME}}
7
+
8
+ services:
9
+ postgres:
10
+ image: postgres:16-alpine
11
+ environment:
12
+ POSTGRES_USER: {{PROJECT_NAME}}
13
+ POSTGRES_PASSWORD: {{PROJECT_NAME}}
14
+ POSTGRES_DB: {{PROJECT_NAME}}_dev
15
+ ports:
16
+ - "5432:5432"
17
+ healthcheck:
18
+ test: ["CMD-SHELL", "pg_isready -U {{PROJECT_NAME}} -d {{PROJECT_NAME}}_dev"]
19
+ interval: 2s
20
+ timeout: 3s
21
+ retries: 30
22
+
23
+ # 세션 스토어(§7.4). 캐시·세션은 Redis 가 기본이다.
24
+ redis:
25
+ image: redis:7-alpine
26
+ ports:
27
+ - "6379:6379"
28
+ healthcheck:
29
+ test: ["CMD", "redis-cli", "ping"]
30
+ interval: 2s
31
+ timeout: 3s
32
+ retries: 30
33
+
34
+ # 실시간 백본(§7 · M6). NATS JetStream(-js) — 채널·잡·프레즌스 KV 를 모두 처리.
35
+ nats:
36
+ image: nats:2.10-alpine
37
+ command: ["-js", "-m", "8222"]
38
+ ports:
39
+ - "4222:4222"
40
+ - "8222:8222"
41
+ healthcheck:
42
+ test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8222/healthz"]
43
+ interval: 2s
44
+ timeout: 3s
45
+ retries: 30
46
+
47
+ # 메일 배터리(§7 · M8). MailPit = SMTP 싱크 + 웹 UI(:8025) — 개발 미리보기의 실 인프라.
48
+ mailpit:
49
+ image: axllent/mailpit:latest
50
+ ports:
51
+ - "1025:1025"
52
+ - "8025:8025"
53
+ healthcheck:
54
+ test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8025/readyz"]
55
+ interval: 2s
56
+ timeout: 3s
57
+ retries: 30
58
+
59
+ # 파일 스토리지(§7 · M8). MinIO = S3 호환 — 운영 R2/S3 와 동일 API.
60
+ minio:
61
+ image: minio/minio:latest
62
+ command: ["server", "/data", "--console-address", ":9001"]
63
+ environment:
64
+ MINIO_ROOT_USER: {{PROJECT_NAME}}
65
+ MINIO_ROOT_PASSWORD: {{PROJECT_NAME}}_secret
66
+ ports:
67
+ - "9000:9000"
68
+ - "9001:9001"
69
+ healthcheck:
70
+ test: ["CMD", "mc", "ready", "local"]
71
+ interval: 2s
72
+ timeout: 3s
73
+ retries: 30
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1,27 @@
1
+ // {{PROJECT_NAME}} 루트 설정 (§3.3). 개발자는 이 파일 하나로 배터리를 켠다.
2
+ // 환경 변수는 .env 에서 로드된다 — gaon dev / gaon serve 가 자동 배선.
3
+ import { defineConfig } from 'gaonjs/config'
4
+
5
+ export default defineConfig({
6
+ // DB — main 커넥션. 스키마에서 { db: '키' } 로 다른 커넥션에 붙일 수 있다(§4.5).
7
+ db: process.env.DATABASE_URL
8
+ ? {
9
+ main: {
10
+ adapter: 'postgres',
11
+ url: process.env.DATABASE_URL,
12
+ },
13
+ }
14
+ : undefined,
15
+
16
+ // Redis — 세션 스토어 · 캐시.
17
+ redis: process.env.REDIS_URL ? { url: process.env.REDIS_URL } : undefined,
18
+
19
+ // NATS — 실시간·비동기 백본(§7). broadcast 전용(errata E-2).
20
+ nats: process.env.NATS_URL ? { url: process.env.NATS_URL } : undefined,
21
+
22
+ // 웹 서버 리슨 옵션. --port · env PORT 로 덮을 수 있다.
23
+ web: {
24
+ port: process.env.PORT ? Number(process.env.PORT) : 3000,
25
+ cookieSecret: process.env.COOKIE_SECRET,
26
+ },
27
+ })
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "scripts": {
10
+ "dev": "gaon dev",
11
+ "serve": "gaon serve",
12
+ "work": "gaon work",
13
+ "hub": "gaon hub",
14
+ "check": "gaon check",
15
+ "doctor": "gaon doctor",
16
+ "test": "vitest run"
17
+ },
18
+ "dependencies": {
19
+ "gaonjs": "{{GAONJS_VERSION}}",
20
+ "@inertiajs/vue3": "^3.6.0",
21
+ "vue": "^3.5.0"
22
+ },
23
+ "devDependencies": {
24
+ "@vitejs/plugin-vue": "^6.0.0",
25
+ "typescript": "^5.9.0",
26
+ "vite": "^7.0.0",
27
+ "vue-tsc": "^3.3.0",
28
+ "vitest": "^3.0.0"
29
+ }
30
+ }
@@ -0,0 +1,11 @@
1
+ # pnpm workspace — 앱은 apps/*, 도메인은 domain/, 공용은 shared/ 아래에 둔다.
2
+ # 이 관례는 CLAUDE.md §2 · v0.15 §3.2 를 따른다(앱과 domain 은 별도 폴더).
3
+ packages:
4
+ - "packages/*"
5
+
6
+ # pnpm 11 부터 build script 실행은 명시적 승인 필요(보안 기본값 = 비 TTY 에서 exit 1).
7
+ # 스캐폴드 첫 install 이 잡히지 않도록 완화하고, 실제 실행 허용 목록만 명시한다.
8
+ # 정본 문서: https://pnpm.io/settings (구 package.json 의 pnpm 필드 대체).
9
+ strictDepBuilds: false
10
+ onlyBuiltDependencies:
11
+ - esbuild
@@ -0,0 +1 @@
1
+ # 이 폴더는 gaon g <type> <name> 로 채워집니다. .gitkeep 은 스캐폴드 관례상 유지.
@@ -0,0 +1,21 @@
1
+ // useDebounce — shared 컴포저블(errata E-5 §2.2).
2
+ // shared 는 shared 컴포넌트의 "props 로만" 규칙과 정확히 대칭이다:
3
+ // gaonjs/vue 서브패스 import 금지 · domain 은 타입 import 만 · 인자로만
4
+ // 받는 순수 로직. (`.gaon/routes.d.ts` 는 앱별 생성이라 구조적으로도
5
+ // 라우트를 몰라야 한다.)
6
+
7
+ /**
8
+ * fn 을 delay(ms) 만큼 지연 실행. 새 호출이 오면 이전 타이머를 취소한다.
9
+ * @param fn 실행할 함수 (인자로 받는다 — shared 는 라우트를 모른다).
10
+ * @param delay 지연 시간(ms).
11
+ */
12
+ export function useDebounce<A extends readonly unknown[]>(
13
+ fn: (...args: A) => void,
14
+ delay: number,
15
+ ): (...args: A) => void {
16
+ let timer: ReturnType<typeof setTimeout> | undefined
17
+ return (...args: A) => {
18
+ if (timer !== undefined) clearTimeout(timer)
19
+ timer = setTimeout(() => fn(...args), delay)
20
+ }
21
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM"],
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "noEmit": true,
13
+ "jsx": "preserve",
14
+ "types": ["node"]
15
+ },
16
+ "include": [
17
+ "apps/**/*.ts",
18
+ "apps/**/*.vue",
19
+ "domain/**/*.ts",
20
+ "shared/**/*.ts",
21
+ "shared/**/*.vue",
22
+ "gaon.config.ts"
23
+ ],
24
+ "exclude": ["node_modules", "dist", ".gaon"]
25
+ }
@@ -0,0 +1,23 @@
1
+ // vite.config.ts — 프론트엔드 빌드/개발 서버 설정 (v0.16 §6.4).
2
+ //
3
+ // The One Way: gaon dev 가 Vite 를 middlewareMode 로 붙여 Fastify 한 포트로
4
+ // 서빙한다(§CLAUDE.md 6 · 이 파일의 server 옵션은 그때 재정의된다). vite
5
+ // build 는 이 파일을 그대로 사용한다.
6
+ //
7
+ // 앱이 하나 이상이면 각 앱마다 vite.config.ts 를 두는 것이 아니라, 이 루트
8
+ // 파일 하나가 root 를 apps/<앱> 으로 잡고 여러 번 실행된다(gaon dev 가 앱별
9
+ // Vite 서버를 띄운다). 관례가 곧 배치.
10
+ import { defineConfig } from 'vite'
11
+ import vue from '@vitejs/plugin-vue'
12
+
13
+ export default defineConfig({
14
+ // 기본 앱은 apps/web · gaon dev 가 다른 앱에 대해 root 를 재정의한다.
15
+ root: 'apps/web',
16
+ plugins: [vue()],
17
+ build: {
18
+ outDir: '../../dist/web',
19
+ emptyOutDir: true,
20
+ // Inertia SPA 는 index.html 하나가 진입 · 라우팅은 서버 몫이다.
21
+ rollupOptions: {},
22
+ },
23
+ })
package/dist/tsResolve.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 생성기(tables·routes)는 사용자 스키마·컨트롤러 .ts 를 런타임 동적
5
5
  * import 해 구조를 읽는다. TS-for-ESM 관례상 상대 import 는 `.js` 로
6
- * 쓰지만(`import { Post } from '../models/post.js'`), 개발 중 소스는
6
+ * 쓰지만(`import { Post } from '../models/Post.js'`), 개발 중 소스는
7
7
  * `.ts` 다 — Node 는 `.js`→`.ts` 재매핑을 하지 않아 그대로면 모듈을
8
8
  * 못 찾는다(vitest 는 Vite 가 재작성해 문제 없음, 실 gaon dev/check 만
9
9
  * 해당). 이 훅이 `.js` 가 없고 형제 `.ts` 가 있으면 `.ts` 로 넘긴다.
package/dist/work.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export interface WorkCommandOptions {
2
2
  readonly json?: boolean;
3
- /** NATS 접속지. 생략 시 GAON_NATS_URL, 그다음 기본(4222). */
3
+ /** NATS 접속지. 생략 시 NATS_URL(그다음 하위호환 GAON_NATS_URL), 기본(4222). */
4
4
  readonly natsUrl?: string;
5
5
  /** 도메인 루트(domain/ 의 부모). 기본 cwd. */
6
6
  readonly root?: string;
7
- /** 아웃박스 릴레이용 DB URL. 생략 시 GAON_DATABASE_URL, 없으면 릴레이 미기동. */
7
+ /** 아웃박스 릴레이용 DB URL. 생략 시 DATABASE_URL(그다음 GAON_DATABASE_URL), 없으면 릴레이 미기동. */
8
8
  readonly databaseUrl?: string;
9
9
  /** 큐 기본 동시성. */
10
10
  readonly concurrency?: number;
package/dist/work.js CHANGED
@@ -68,7 +68,9 @@ export async function runWorkCommand(opts = {}) {
68
68
  };
69
69
  const nats = await connectNats({ servers: opts.natsUrl, name: `work@${id}` });
70
70
  let db;
71
- const dbUrl = opts.databaseUrl ?? process.env.GAON_DATABASE_URL;
71
+ // 스캐폴드 .env 관례(DATABASE_URL)를 정본으로 · GAON_DATABASE_URL 은 하위호환.
72
+ // serve(config 경유 DATABASE_URL)와 work 가 같은 env 를 보게 한다.
73
+ const dbUrl = opts.databaseUrl ?? process.env.DATABASE_URL ?? process.env.GAON_DATABASE_URL;
72
74
  if (dbUrl)
73
75
  db = createDb(dbConfigFromUrl(dbUrl));
74
76
  const domain = await loadDomain(root);