@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
@@ -2,6 +2,8 @@
2
2
  import { computed } from 'vue'
3
3
  import { pageProps } from 'gaonjs/vue'
4
4
  import { useGaonHealth, type HealthDoctor } from '../../composables/useGaonHealth.js'
5
+ import Card from '../../components/ui/Card.vue'
6
+ import Badge from '../../components/ui/Badge.vue'
5
7
 
6
8
  // home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
7
9
  // 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
@@ -11,7 +13,7 @@ const props = pageProps<'web:home#index'>()
11
13
  const { health, loading, available } = useGaonHealth()
12
14
 
13
15
  type CardStatus = 'ok' | 'warn' | 'error' | 'idle'
14
- interface Card {
16
+ interface StatusCard {
15
17
  readonly key: string
16
18
  readonly title: string
17
19
  readonly status: CardStatus
@@ -25,14 +27,14 @@ function doctorNote(d: HealthDoctor | undefined): string {
25
27
  return `${d.passed} 통과 · ${d.warnings} 경고 · ${d.errors} 오류`
26
28
  }
27
29
 
28
- const cards = computed<Card[]>(() => {
30
+ const cards = computed<StatusCard[]>(() => {
29
31
  const h = health.value
30
32
  const web = h?.web
31
33
  const db = h?.database
32
34
  const hub = h?.hub
33
35
  const doc = h?.doctor
34
36
 
35
- const webCard: Card = {
37
+ const webCard: StatusCard = {
36
38
  key: 'web',
37
39
  title: 'WEB',
38
40
  status: available.value ? 'ok' : 'idle',
@@ -42,7 +44,7 @@ const cards = computed<Card[]>(() => {
42
44
  : ['개발 서버에서만'],
43
45
  }
44
46
 
45
- const dbCard: Card = {
47
+ const dbCard: StatusCard = {
46
48
  key: 'database',
47
49
  title: 'DATABASE',
48
50
  status: !db || !db.configured ? 'idle' : db.connected ? 'ok' : 'error',
@@ -55,7 +57,7 @@ const cards = computed<Card[]>(() => {
55
57
  : ['.env 에 DATABASE_URL', 'gaon.config.ts 에서 켜기'],
56
58
  }
57
59
 
58
- const hubCard: Card = {
60
+ const hubCard: StatusCard = {
59
61
  key: 'hub',
60
62
  title: 'HUB',
61
63
  status: !hub || !hub.configured ? 'idle' : hub.connected ? 'ok' : 'error',
@@ -63,12 +65,16 @@ const cards = computed<Card[]>(() => {
63
65
  lines:
64
66
  hub && hub.configured
65
67
  ? hub.connected
66
- ? (hub.streams ?? []).map((s) => `${s.stream}: ${s.waiting === null ? '대기 없음' : `${s.waiting} 대기`}`)
68
+ ? [
69
+ // jobs = 스트림 대기 수 · channels = 프레즌스 실 접속자 수(결정 71).
70
+ ...(hub.streams ?? []).map((s) => `${s.stream}: ${s.waiting === null ? '대기 없음' : `${s.waiting} 대기`}`),
71
+ `채널 접속 ${(hub.channels ?? []).reduce((n, c) => n + c.members, 0)}명`,
72
+ ]
67
73
  : [hub.error ?? 'NATS 미연결']
68
74
  : ['.env 에 NATS_URL', '실시간·비동기 백본'],
69
75
  }
70
76
 
71
- const doctorCard: Card = {
77
+ const doctorCard: StatusCard = {
72
78
  key: 'doctor',
73
79
  title: 'DOCTOR',
74
80
  status: !doc ? 'idle' : doc.level === 'pass' ? 'ok' : doc.level === 'warn' ? 'warn' : 'error',
@@ -87,6 +93,14 @@ const cards = computed<Card[]>(() => {
87
93
  return [webCard, dbCard, hubCard, doctorCard]
88
94
  })
89
95
 
96
+ // 상태 → 점 색(Tailwind). idle 은 은은하게, 나머지는 신호색.
97
+ const DOT: Record<CardStatus, string> = {
98
+ ok: 'bg-green-500',
99
+ warn: 'bg-yellow-500',
100
+ error: 'bg-red-500',
101
+ idle: 'bg-muted-foreground/40',
102
+ }
103
+
90
104
  // 다음 단계 — 라이브 상태에서 완료 여부를 파생한다.
91
105
  interface Step {
92
106
  readonly label: string
@@ -155,265 +169,68 @@ const routeLines = computed<CodeLine[]>(() => {
155
169
  </script>
156
170
 
157
171
  <template>
158
- <div class="landing">
159
- <section class="hero">
160
- <p class="eyebrow">{{ props.title }}</p>
161
- <h1>가온에 올라탔습니다.</h1>
162
- <p class="lede">
163
- 이 화면은 <code>apps/web/pages/Home/Index.vue</code> 입니다.
164
- <code>routes.ts</code> 의 <code>r.get('/', 'home#index')</code> 가 여기로 연결했습니다.
172
+ <div class="mx-auto max-w-[880px] px-5 pb-16 pt-12 leading-relaxed">
173
+ <section class="mb-10">
174
+ <p class="mb-1.5 text-xs font-semibold uppercase tracking-[0.08em] text-[#7c5cff]">{{ props.title }}</p>
175
+ <h1 class="mb-3.5 text-[clamp(2rem,5vw,2.9rem)] font-bold leading-[1.15] tracking-tight">가온에 올라탔습니다.</h1>
176
+ <p class="my-1.5 text-[1.02rem] text-foreground/80">
177
+ 이 화면은 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">apps/web/pages/Home/Index.vue</code> 입니다.
178
+ <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">routes.ts</code> 의 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">r.get('/', 'home#index')</code> 가 여기로 연결했습니다.
165
179
  </p>
166
- <p class="lede sub">
167
- 아래 카드는 지금 이 서버의 <strong>실제 상태</strong>입니다 —
168
- 하드코딩이 아니라 <code>/_gaon/health</code> 를 읽어 그립니다.
169
- <a :href="props.docs" target="_blank" rel="noreferrer">{{ props.docs }}</a>
180
+ <p class="my-1.5 text-[0.95rem] text-muted-foreground">
181
+ 아래 카드는 지금 이 서버의 <strong class="font-semibold text-foreground">실제 상태</strong>입니다 —
182
+ 하드코딩이 아니라 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">/_gaon/health</code> 를 읽어 그립니다.
183
+ <a :href="props.docs" target="_blank" rel="noreferrer" class="text-[#4f8cff] hover:underline">{{ props.docs }}</a>
170
184
  </p>
171
185
  </section>
172
186
 
173
- <section class="cards" aria-label="프레임웍 상태">
174
- <article v-for="c in cards" :key="c.key" class="card" :class="`is-${c.status}`">
175
- <header class="card-top">
176
- <span class="dot" :class="`dot-${c.status}`" aria-hidden="true"></span>
177
- <span class="card-title">{{ c.title }}</span>
178
- </header>
179
- <p class="card-headline">{{ c.headline }}</p>
180
- <ul class="card-lines">
181
- <li v-for="(ln, i) in c.lines" :key="i">{{ ln }}</li>
187
+ <section class="mb-4 grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4" aria-label="프레임웍 상태">
188
+ <Card v-for="c in cards" :key="c.key" class="card p-[1.15rem]">
189
+ <div class="mb-2.5 flex items-center gap-2">
190
+ <span class="h-2.5 w-2.5 rounded-full" :class="DOT[c.status]" aria-hidden="true"></span>
191
+ <span class="text-[0.74rem] font-bold tracking-[0.08em] text-muted-foreground">{{ c.title }}</span>
192
+ </div>
193
+ <p class="mb-2 text-[1.35rem] font-bold tabular-nums tracking-tight">{{ c.headline }}</p>
194
+ <ul class="m-0 list-none p-0 text-[0.82rem] text-muted-foreground">
195
+ <li v-for="(ln, i) in c.lines" :key="i" class="break-words py-[0.08rem]">{{ ln }}</li>
182
196
  </ul>
183
- </article>
197
+ </Card>
184
198
  </section>
185
199
 
186
- <p v-if="loading" class="hint">상태 확인 중…</p>
187
- <p v-else-if="!available" class="hint">
188
- 라이브 상태는 <code>gaon dev</code> 개발 서버에서만 보입니다(운영 빌드엔 진단 엔드포인트가 없습니다).
200
+ <p v-if="loading" class="my-1 mb-6 text-sm text-muted-foreground">상태 확인 중…</p>
201
+ <p v-else-if="!available" class="my-1 mb-6 text-sm text-muted-foreground">
202
+ 라이브 상태는 <code class="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.86em]">gaon dev</code> 개발 서버에서만 보입니다(운영 빌드엔 진단 엔드포인트가 없습니다).
189
203
  </p>
190
204
 
191
- <section class="next">
192
- <h2>다음 단계</h2>
193
- <ul class="steps">
194
- <li v-for="s in steps" :key="s.label" :class="{ done: s.done }">
195
- <span class="check" aria-hidden="true">{{ s.done ? '✓' : '○' }}</span>
196
- <div class="step-body">
197
- <div class="step-head">
198
- <span class="step-label">{{ s.label }}</span>
199
- <code class="step-cmd">{{ s.cmd }}</code>
205
+ <section class="my-10">
206
+ <h2 class="mb-4 text-[1.15rem] font-semibold">다음 단계</h2>
207
+ <ul class="flex list-none flex-col gap-1.5 p-0">
208
+ <li
209
+ v-for="s in steps"
210
+ :key="s.label"
211
+ class="flex items-start gap-3 rounded-[10px] border px-3.5 py-2.5"
212
+ :class="s.done ? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30' : 'border-border bg-muted/30'"
213
+ >
214
+ <span class="font-bold leading-6" :class="s.done ? 'text-green-600' : 'text-muted-foreground/60'" aria-hidden="true">{{ s.done ? '✓' : '○' }}</span>
215
+ <div class="min-w-0 flex-1">
216
+ <div class="flex flex-wrap items-center gap-2.5">
217
+ <span class="font-semibold">{{ s.label }}</span>
218
+ <code class="rounded bg-[#0d1117] px-1.5 py-0.5 font-mono text-[0.86em] text-[#e6edf3]">{{ s.cmd }}</code>
200
219
  </div>
201
- <span class="step-note">{{ s.note }}</span>
220
+ <span class="mt-0.5 block text-xs text-muted-foreground">{{ s.note }}</span>
202
221
  </div>
203
222
  </li>
204
223
  </ul>
205
224
  </section>
206
225
 
207
- <section v-if="routeLines.length" class="code">
208
- <div class="code-head">{{ routesPath }}</div>
209
- <pre class="code-body"><code><span v-for="(ln, i) in routeLines" :key="i" class="code-line"><span class="c-code">{{ ln.code }}</span><span class="c-comment">{{ ln.comment }}</span>{{ '\n' }}</span></code></pre>
226
+ <section v-if="routeLines.length" class="overflow-hidden rounded-xl border">
227
+ <div class="border-b bg-muted px-4 py-2 font-mono text-xs text-muted-foreground">{{ routesPath }}</div>
228
+ <pre class="m-0 overflow-x-auto bg-[#0d1117] px-[1.15rem] py-4 text-[0.82rem] leading-[1.65] text-[#c9d1d9]"><code><span v-for="(ln, i) in routeLines" :key="i" class="block"><span>{{ ln.code }}</span><span class="text-[#8b949e]">{{ ln.comment }}</span>{{ '\n' }}</span></code></pre>
210
229
  </section>
230
+
231
+ <p class="mt-8 text-center text-xs text-muted-foreground">
232
+ <Badge variant="secondary">UI 킷</Badge>
233
+ 이 화면·카드·다음 단계는 gaon g ui-kit 로 심은 컴포넌트로 그렸습니다 — apps/web/components/ui/ 에서 소유·수정하세요.
234
+ </p>
211
235
  </div>
212
236
  </template>
213
-
214
- <style scoped>
215
- .landing {
216
- max-width: 880px;
217
- margin: 0 auto;
218
- padding: 3rem 1.25rem 4rem;
219
- color: #1b1f24;
220
- line-height: 1.6;
221
- }
222
-
223
- .hero {
224
- margin-bottom: 2.5rem;
225
- }
226
- .eyebrow {
227
- margin: 0 0 0.4rem;
228
- font-size: 0.8rem;
229
- font-weight: 600;
230
- letter-spacing: 0.08em;
231
- text-transform: uppercase;
232
- color: #7c5cff;
233
- }
234
- .hero h1 {
235
- margin: 0 0 0.9rem;
236
- font-size: clamp(2rem, 5vw, 2.9rem);
237
- line-height: 1.15;
238
- letter-spacing: -0.02em;
239
- }
240
- .lede {
241
- margin: 0.35rem 0;
242
- color: #3a424c;
243
- font-size: 1.02rem;
244
- }
245
- .lede.sub {
246
- color: #57606a;
247
- font-size: 0.95rem;
248
- }
249
- .lede a {
250
- color: #4f8cff;
251
- }
252
- code {
253
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
254
- font-size: 0.86em;
255
- background: #f2f4f7;
256
- padding: 0.1rem 0.35rem;
257
- border-radius: 5px;
258
- }
259
-
260
- .cards {
261
- display: grid;
262
- grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
263
- gap: 1rem;
264
- margin-bottom: 1rem;
265
- }
266
- .card {
267
- border: 1px solid #e4e7eb;
268
- border-radius: 12px;
269
- padding: 1.1rem 1.15rem;
270
- background: #fff;
271
- box-shadow: 0 1px 2px rgba(16, 22, 26, 0.04);
272
- }
273
- .card-top {
274
- display: flex;
275
- align-items: center;
276
- gap: 0.5rem;
277
- margin-bottom: 0.6rem;
278
- }
279
- .card-title {
280
- font-size: 0.74rem;
281
- font-weight: 700;
282
- letter-spacing: 0.08em;
283
- color: #6a737d;
284
- }
285
- .dot {
286
- width: 9px;
287
- height: 9px;
288
- border-radius: 50%;
289
- background: #c2c8cf;
290
- }
291
- .dot-ok {
292
- background: #2da44e;
293
- box-shadow: 0 0 0 3px rgba(45, 164, 78, 0.15);
294
- }
295
- .dot-warn {
296
- background: #d4a72c;
297
- box-shadow: 0 0 0 3px rgba(212, 167, 44, 0.15);
298
- }
299
- .dot-error {
300
- background: #cf222e;
301
- box-shadow: 0 0 0 3px rgba(207, 34, 46, 0.15);
302
- }
303
- .card-headline {
304
- margin: 0 0 0.5rem;
305
- font-size: 1.35rem;
306
- font-weight: 700;
307
- letter-spacing: -0.01em;
308
- font-variant-numeric: tabular-nums;
309
- }
310
- .card-lines {
311
- margin: 0;
312
- padding: 0;
313
- list-style: none;
314
- font-size: 0.82rem;
315
- color: #57606a;
316
- }
317
- .card-lines li {
318
- padding: 0.08rem 0;
319
- overflow-wrap: anywhere;
320
- }
321
-
322
- .hint {
323
- margin: 0.25rem 0 1.5rem;
324
- font-size: 0.85rem;
325
- color: #6a737d;
326
- }
327
-
328
- .next {
329
- margin: 2.5rem 0;
330
- }
331
- .next h2 {
332
- font-size: 1.15rem;
333
- margin: 0 0 1rem;
334
- }
335
- .steps {
336
- list-style: none;
337
- margin: 0;
338
- padding: 0;
339
- display: flex;
340
- flex-direction: column;
341
- gap: 0.35rem;
342
- }
343
- .steps li {
344
- display: flex;
345
- gap: 0.75rem;
346
- align-items: flex-start;
347
- padding: 0.65rem 0.85rem;
348
- border: 1px solid #eaecef;
349
- border-radius: 10px;
350
- background: #fbfcfd;
351
- }
352
- .steps li.done {
353
- border-color: #cbe7d3;
354
- background: #f3faf5;
355
- }
356
- .check {
357
- color: #b0b7bf;
358
- font-weight: 700;
359
- line-height: 1.5;
360
- }
361
- .steps li.done .check {
362
- color: #2da44e;
363
- }
364
- .step-body {
365
- flex: 1;
366
- min-width: 0;
367
- }
368
- .step-head {
369
- display: flex;
370
- align-items: center;
371
- gap: 0.6rem;
372
- flex-wrap: wrap;
373
- }
374
- .step-label {
375
- font-weight: 600;
376
- }
377
- .step-cmd {
378
- background: #0d1117;
379
- color: #e6edf3;
380
- }
381
- .step-note {
382
- display: block;
383
- font-size: 0.8rem;
384
- color: #6a737d;
385
- margin-top: 0.15rem;
386
- }
387
-
388
- .code {
389
- border: 1px solid #e4e7eb;
390
- border-radius: 12px;
391
- overflow: hidden;
392
- }
393
- .code-head {
394
- padding: 0.55rem 1rem;
395
- background: #f6f8fa;
396
- border-bottom: 1px solid #e4e7eb;
397
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
398
- font-size: 0.78rem;
399
- color: #57606a;
400
- }
401
- .code-body {
402
- margin: 0;
403
- padding: 1rem 1.15rem;
404
- overflow-x: auto;
405
- background: #0d1117;
406
- color: #c9d1d9;
407
- font-size: 0.82rem;
408
- line-height: 1.65;
409
- }
410
- .code-body code {
411
- background: none;
412
- padding: 0;
413
- font-size: inherit;
414
- color: inherit;
415
- }
416
- .c-comment {
417
- color: #8b949e;
418
- }
419
- </style>
@@ -0,0 +1,4 @@
1
+ # {{PROJECT_NAME}} · 정적 파일 폴더 (결정 85) — 이 폴더의 파일은 앱 루트로 서빙된다.
2
+ # 예: 이 robots.txt 는 /robots.txt 로, static/img/logo.png 은 /img/logo.png 으로.
3
+ User-agent: *
4
+ Disallow:
@@ -0,0 +1,66 @@
1
+ /* apps/web/style.css — 앱 전역 스타일 (결정 74).
2
+ *
3
+ * main.ts 가 이 파일을 import 해 번들에 싣는다. @tailwind 3줄이 Tailwind 의
4
+ * base/components/utilities 레이어를 펼친다(PostCSS · postcss.config.js).
5
+ *
6
+ * :root/.dark 의 CSS 변수 = 디자인 토큰(shadcn 관례). UI 킷 컴포넌트는 이
7
+ * 토큰(bg-primary·text-muted-foreground …)만 참조하므로, 브랜드 색을 바꾸려면
8
+ * 여기 한 곳만 고친다. hsl 채널 값으로 두는 이유는 tailwind.config.ts 가
9
+ * hsl(var(--token)) 로 감싸 투명도 유틸(bg-primary/50)까지 동작하게 하기 위함이다. */
10
+ @tailwind base;
11
+ @tailwind components;
12
+ @tailwind utilities;
13
+
14
+ @layer base {
15
+ :root {
16
+ --background: 0 0% 100%;
17
+ --foreground: 240 10% 3.9%;
18
+ --card: 0 0% 100%;
19
+ --card-foreground: 240 10% 3.9%;
20
+ --primary: 240 5.9% 10%;
21
+ --primary-foreground: 0 0% 98%;
22
+ --secondary: 240 4.8% 95.9%;
23
+ --secondary-foreground: 240 5.9% 10%;
24
+ --muted: 240 4.8% 95.9%;
25
+ --muted-foreground: 240 3.8% 46.1%;
26
+ --accent: 240 4.8% 95.9%;
27
+ --accent-foreground: 240 5.9% 10%;
28
+ --destructive: 0 84.2% 60.2%;
29
+ --destructive-foreground: 0 0% 98%;
30
+ --border: 240 5.9% 90%;
31
+ --input: 240 5.9% 90%;
32
+ --ring: 240 5.9% 10%;
33
+ --radius: 0.5rem;
34
+ }
35
+
36
+ .dark {
37
+ --background: 240 10% 3.9%;
38
+ --foreground: 0 0% 98%;
39
+ --card: 240 10% 3.9%;
40
+ --card-foreground: 0 0% 98%;
41
+ --primary: 0 0% 98%;
42
+ --primary-foreground: 240 5.9% 10%;
43
+ --secondary: 240 3.7% 15.9%;
44
+ --secondary-foreground: 0 0% 98%;
45
+ --muted: 240 3.7% 15.9%;
46
+ --muted-foreground: 240 5% 64.9%;
47
+ --accent: 240 3.7% 15.9%;
48
+ --accent-foreground: 0 0% 98%;
49
+ --destructive: 0 62.8% 30.6%;
50
+ --destructive-foreground: 0 0% 98%;
51
+ --border: 240 3.7% 15.9%;
52
+ --input: 240 3.7% 15.9%;
53
+ --ring: 240 4.9% 83.9%;
54
+ }
55
+
56
+ * {
57
+ border-color: hsl(var(--border));
58
+ }
59
+
60
+ body {
61
+ background-color: hsl(var(--background));
62
+ color: hsl(var(--foreground));
63
+ font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
64
+ -webkit-font-smoothing: antialiased;
65
+ }
66
+ }
@@ -0,0 +1,98 @@
1
+ # {{PROJECT_NAME}} 운영 스택 — 웹·워커·허브 3종 프로세스 + 실 인프라.
2
+ #
3
+ # 개발 스택(docker-compose.yaml)과 달리 앱 이미지(Dockerfile)를 빌드해 serve·work·
4
+ # hub 를 별도 서비스로 띄운다(운영 프로세스 3종 · CLAUDE.md §2 · 결정 60). 인프라는
5
+ # 실 pg·redis·nats(목업 금지 §9). 시크릿은 env 로 주입한다 — 이 파일에 값을 박지 말 것.
6
+ #
7
+ # 기동: docker compose -f compose.prod.yaml up -d --build
8
+ # 정지: docker compose -f compose.prod.yaml down
9
+ name: {{PROJECT_NAME}}-prod
10
+
11
+ x-app-env: &app-env
12
+ DATABASE_URL: postgres://{{PROJECT_NAME}}:${DB_PASSWORD:?set DB_PASSWORD}@postgres:5432/{{PROJECT_NAME}}
13
+ REDIS_URL: redis://redis:6379
14
+ NATS_URL: nats://nats:4222
15
+ COOKIE_SECRET: ${COOKIE_SECRET:?set COOKIE_SECRET (32+ chars)}
16
+ NODE_ENV: production
17
+
18
+ services:
19
+ # 웹 프로세스(gaon serve). node:cluster 워커 수는 WEB_CONCURRENCY 로(기본 1).
20
+ web:
21
+ build: .
22
+ command: ["pnpm", "serve"]
23
+ environment:
24
+ <<: *app-env
25
+ PORT: "3000"
26
+ WEB_CONCURRENCY: "${WEB_CONCURRENCY:-1}"
27
+ ports:
28
+ - "3000:3000"
29
+ depends_on:
30
+ postgres: { condition: service_healthy }
31
+ redis: { condition: service_healthy }
32
+ nats: { condition: service_healthy }
33
+ restart: unless-stopped
34
+
35
+ # 워커 프로세스(gaon work) — 잡·리스너·스케줄러·아웃박스 릴레이.
36
+ worker:
37
+ build: .
38
+ command: ["pnpm", "work"]
39
+ environment: *app-env
40
+ depends_on:
41
+ postgres: { condition: service_healthy }
42
+ nats: { condition: service_healthy }
43
+ restart: unless-stopped
44
+
45
+ # 허브 프로세스(gaon hub) — 리더만 bind, 프레즌스 단일 권위(결정 60 · §7).
46
+ hub:
47
+ build: .
48
+ command: ["pnpm", "hub"]
49
+ environment:
50
+ <<: *app-env
51
+ GAON_HUB_PORT: "4001"
52
+ GAON_HUB_ADVERTISE: "hub:4001"
53
+ depends_on:
54
+ nats: { condition: service_healthy }
55
+ restart: unless-stopped
56
+
57
+ postgres:
58
+ image: postgres:16-alpine
59
+ environment:
60
+ POSTGRES_USER: {{PROJECT_NAME}}
61
+ POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD}
62
+ POSTGRES_DB: {{PROJECT_NAME}}
63
+ volumes:
64
+ - pgdata:/var/lib/postgresql/data
65
+ healthcheck:
66
+ test: ["CMD-SHELL", "pg_isready -U {{PROJECT_NAME}} -d {{PROJECT_NAME}}"]
67
+ interval: 5s
68
+ timeout: 3s
69
+ retries: 30
70
+ restart: unless-stopped
71
+
72
+ redis:
73
+ image: redis:7-alpine
74
+ volumes:
75
+ - redisdata:/data
76
+ healthcheck:
77
+ test: ["CMD", "redis-cli", "ping"]
78
+ interval: 5s
79
+ timeout: 3s
80
+ retries: 30
81
+ restart: unless-stopped
82
+
83
+ nats:
84
+ image: nats:2.10-alpine
85
+ command: ["-js", "-sd", "/data", "-m", "8222"]
86
+ volumes:
87
+ - natsdata:/data
88
+ healthcheck:
89
+ test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8222/healthz"]
90
+ interval: 5s
91
+ timeout: 3s
92
+ retries: 30
93
+ restart: unless-stopped
94
+
95
+ volumes:
96
+ pgdata:
97
+ redisdata:
98
+ natsdata:
@@ -3,6 +3,7 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
+ "packageManager": "pnpm@10.27.0",
6
7
  "engines": {
7
8
  "node": ">=22"
8
9
  },
@@ -24,6 +25,9 @@
24
25
  "devDependencies": {
25
26
  "@types/node": "^22.0.0",
26
27
  "@vitejs/plugin-vue": "^6.0.0",
28
+ "autoprefixer": "^10.4.0",
29
+ "postcss": "^8.4.0",
30
+ "tailwindcss": "^3.4.0",
27
31
  "typescript": "^5.9.0",
28
32
  "vite": "^7.0.0",
29
33
  "vue-tsc": "^3.3.0",
@@ -0,0 +1,15 @@
1
+ // postcss.config.js — Vite 가 CSS 를 처리할 때 타는 PostCSS 파이프라인 (결정 74).
2
+ //
3
+ // Vite 는 프로젝트 루트에서 이 파일을 자동으로 찾아 style.css 의 @tailwind
4
+ // 지시문을 실 유틸 클래스로 펼치고(tailwindcss), 벤더 프리픽스를 붙인다
5
+ // (autoprefixer). gaon dev(Vite 미들웨어)·vite build 가 같은 설정을 공유한다.
6
+ //
7
+ // PostCSS 설정은 .js 가 표준이다 — Vite 의 postcss-load-config 가 .ts 를 바로
8
+ // 로드하지 못한다. 프레임웍의 "TS 전용" 관례는 앱/도메인 소스에 대한 것이고,
9
+ // 툴 설정 파일은 각 도구가 요구하는 형식을 따른다.
10
+ export default {
11
+ plugins: {
12
+ tailwindcss: {},
13
+ autoprefixer: {},
14
+ },
15
+ }
@@ -0,0 +1,56 @@
1
+ // tailwind.config.ts — Tailwind CSS 설정 (결정 74).
2
+ //
3
+ // content 는 유틸 클래스가 등장하는 모든 소스를 가리킨다. 앱 페이지·컴포넌트와
4
+ // shared UI 를 훑는다(자동 import 금지 관례와 무관 — 여기선 클래스 문자열 스캔).
5
+ // darkMode: 'class' — <html class="dark"> 로 다크 테마를 켠다(토큰은 style.css).
6
+ //
7
+ // 색 토큰은 style.css 의 CSS 변수(hsl 채널)를 가리킨다(shadcn 관례). 컴포넌트는
8
+ // bg-primary·text-muted-foreground 처럼 의미 토큰만 쓰고, 실제 색은 style.css 의
9
+ // :root/.dark 한 곳에서 바꾼다 — 라이트/다크가 한 지점에서 갈린다.
10
+ import type { Config } from 'tailwindcss'
11
+
12
+ export default {
13
+ darkMode: 'class',
14
+ content: ['./apps/**/*.{vue,ts}', './shared/**/*.{vue,ts}', './apps/**/index.html'],
15
+ theme: {
16
+ extend: {
17
+ colors: {
18
+ border: 'hsl(var(--border))',
19
+ input: 'hsl(var(--input))',
20
+ ring: 'hsl(var(--ring))',
21
+ background: 'hsl(var(--background))',
22
+ foreground: 'hsl(var(--foreground))',
23
+ primary: {
24
+ DEFAULT: 'hsl(var(--primary))',
25
+ foreground: 'hsl(var(--primary-foreground))',
26
+ },
27
+ secondary: {
28
+ DEFAULT: 'hsl(var(--secondary))',
29
+ foreground: 'hsl(var(--secondary-foreground))',
30
+ },
31
+ destructive: {
32
+ DEFAULT: 'hsl(var(--destructive))',
33
+ foreground: 'hsl(var(--destructive-foreground))',
34
+ },
35
+ muted: {
36
+ DEFAULT: 'hsl(var(--muted))',
37
+ foreground: 'hsl(var(--muted-foreground))',
38
+ },
39
+ accent: {
40
+ DEFAULT: 'hsl(var(--accent))',
41
+ foreground: 'hsl(var(--accent-foreground))',
42
+ },
43
+ card: {
44
+ DEFAULT: 'hsl(var(--card))',
45
+ foreground: 'hsl(var(--card-foreground))',
46
+ },
47
+ },
48
+ borderRadius: {
49
+ lg: 'var(--radius)',
50
+ md: 'calc(var(--radius) - 2px)',
51
+ sm: 'calc(var(--radius) - 4px)',
52
+ },
53
+ },
54
+ },
55
+ plugins: [],
56
+ } satisfies Config
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ // UI 킷 · Alert (결정 75). 인라인 알림. variant='destructive' 로 오류 톤.
3
+ import { computed } from 'vue'
4
+ import { cn } from '../../lib/utils.js'
5
+
6
+ type Variant = 'default' | 'destructive'
7
+
8
+ const props = withDefaults(defineProps<{ variant?: Variant }>(), { variant: 'default' })
9
+
10
+ const VARIANTS: Record<Variant, string> = {
11
+ default: 'bg-background text-foreground',
12
+ destructive: 'border-destructive/50 text-destructive',
13
+ }
14
+ const classes = computed(() =>
15
+ cn('relative w-full rounded-lg border p-4', VARIANTS[props.variant]),
16
+ )
17
+ </script>
18
+
19
+ <template>
20
+ <div role="alert" :class="classes">
21
+ <slot />
22
+ </div>
23
+ </template>