@gaonjs/cli 0.14.0 → 0.18.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 (57) hide show
  1. package/dist/commands/dev.js +3 -1
  2. package/dist/commands/g.js +8 -0
  3. package/dist/commands/new.js +5 -0
  4. package/dist/dev/health.d.ts +75 -0
  5. package/dist/dev/health.js +156 -0
  6. package/dist/doctor/types.d.ts +1 -1
  7. package/dist/doctor/types.js +3 -3
  8. package/dist/doctor/ui-kit-wiring.d.ts +5 -0
  9. package/dist/doctor/ui-kit-wiring.js +93 -0
  10. package/dist/doctor.d.ts +13 -0
  11. package/dist/doctor.js +57 -26
  12. package/dist/generate.js +13 -2
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +20 -6
  15. package/dist/scaffold/app-wiring.d.ts +12 -0
  16. package/dist/scaffold/app-wiring.js +68 -0
  17. package/dist/serve.d.ts +6 -0
  18. package/dist/serve.js +9 -0
  19. package/dist/templates/auth/Dashboard.vue.tpl +21 -5
  20. package/dist/templates/auth/Login.vue.tpl +37 -8
  21. package/dist/templates/auth/Signup.vue.tpl +40 -9
  22. package/dist/templates/auth/registration.controller.ts.tpl +1 -1
  23. package/dist/templates/auth/session.controller.ts.tpl +2 -2
  24. package/dist/templates/project/AGENTS.md.tpl +3 -2
  25. package/dist/templates/project/CLAUDE.md.tpl +3 -2
  26. package/dist/templates/project/agents/async.md.tpl +15 -7
  27. package/dist/templates/project/agents/frontend.md.tpl +79 -1
  28. package/dist/templates/project/apps/web/composables/useGaonHealth.ts.tpl +81 -0
  29. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +30 -29
  30. package/dist/templates/project/apps/web/main.ts.tpl +4 -0
  31. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +225 -25
  32. package/dist/templates/project/apps/web/style.css.tpl +66 -0
  33. package/dist/templates/project/package.json.tpl +3 -0
  34. package/dist/templates/project/postcss.config.js.tpl +15 -0
  35. package/dist/templates/project/tailwind.config.ts.tpl +56 -0
  36. package/dist/templates/ui-kit/Alert.vue.tpl +23 -0
  37. package/dist/templates/ui-kit/AlertDescription.vue.tpl +9 -0
  38. package/dist/templates/ui-kit/AlertTitle.vue.tpl +9 -0
  39. package/dist/templates/ui-kit/Badge.vue.tpl +25 -0
  40. package/dist/templates/ui-kit/Button.vue.tpl +39 -0
  41. package/dist/templates/ui-kit/Card.vue.tpl +10 -0
  42. package/dist/templates/ui-kit/CardContent.vue.tpl +9 -0
  43. package/dist/templates/ui-kit/CardDescription.vue.tpl +9 -0
  44. package/dist/templates/ui-kit/CardFooter.vue.tpl +9 -0
  45. package/dist/templates/ui-kit/CardHeader.vue.tpl +9 -0
  46. package/dist/templates/ui-kit/CardTitle.vue.tpl +9 -0
  47. package/dist/templates/ui-kit/Dialog.vue.tpl +68 -0
  48. package/dist/templates/ui-kit/Form.vue.tpl +13 -0
  49. package/dist/templates/ui-kit/FormField.vue.tpl +16 -0
  50. package/dist/templates/ui-kit/FormMessage.vue.tpl +9 -0
  51. package/dist/templates/ui-kit/Input.vue.tpl +22 -0
  52. package/dist/templates/ui-kit/Label.vue.tpl +9 -0
  53. package/dist/templates/ui-kit/Sheet.vue.tpl +73 -0
  54. package/dist/templates/ui-kit/utils.ts.tpl +26 -0
  55. package/dist/uikit.d.ts +28 -0
  56. package/dist/uikit.js +138 -0
  57. package/package.json +6 -6
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { runNewCommand } from "./commands/new.js";
17
17
  import { runConsoleCommand } from "./commands/console.js";
18
18
  import { runTestCommand } from "./commands/test.js";
19
19
  import { runGenerateAuthCommand } from "./generate.js";
20
+ import { runGenerateUiKitCommand } from "./uikit.js";
20
21
  import { runGenerateCommand } from "./commands/g.js";
21
22
  import { runHubCommand } from "./hub.js";
22
23
  import { runServeCommand } from "./serve.js";
@@ -25,7 +26,7 @@ import { runJobsCommand } from "./jobs.js";
25
26
  import { runDbCommand } from "./commands/db.js";
26
27
  import { runDoctorCommand } from "./doctor.js";
27
28
  import { runMcpCommand } from "./commands/mcp.js";
28
- export { startDev, resolveDevLayout, } from "./dev.js";
29
+ export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
29
30
  export { runDevCommand } from "./commands/dev.js";
30
31
  export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, } from "./dev/index.js";
31
32
  export { runCheckCommand, } from "./commands/check.js";
@@ -33,15 +34,19 @@ export { runNewCommand } from "./commands/new.js";
33
34
  export { runConsoleCommand } from "./commands/console.js";
34
35
  export { runTestCommand } from "./commands/test.js";
35
36
  export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, } from "./generate.js";
37
+ export { writeUiKitScaffold, writeUiKitFiles, uiKitScaffoldFiles, authUiKitFiles, runGenerateUiKitCommand, } from "./uikit.js";
36
38
  export { runGenerateCommand, planScaffold, parseGenerateArgs, } from "./commands/g.js";
37
39
  export { runHubCommand } from "./hub.js";
38
40
  export { runServeCommand } from "./serve.js";
41
+ // dev 전용 라이브 헬스(결정 69). serve(--dev)가 등록하고, 브라우저 e2e 층이
42
+ // serve --dev 를 재현하기 위해 재사용한다.
43
+ export { computeHealth, DEV_HEALTH_PATH, } from "./dev/health.js";
39
44
  export { runWorkCommand } from "./work.js";
40
45
  export { runJobsCommand } from "./jobs.js";
41
46
  export { runDbSeedCommand, loadSeed } from "./db.js";
42
47
  export { runDbCommand, } from "./commands/db.js";
43
48
  export { runDbDiff, runDbMigrate, runDbReset, resolveDbTarget, } from "./db/index.js";
44
- export { runDoctorCommand, runDoctor, runDoctorFix, renderFixHuman, inspectControllerSource, checkResponseMixing, checkNPlusOne, checkDependencyDirection, checkConnections, checkMigrationDiff, renderHuman, renderJson, } from "./doctor.js";
49
+ export { runDoctorCommand, computeDoctorResult, runDoctor, runDoctorFix, renderFixHuman, inspectControllerSource, checkResponseMixing, checkNPlusOne, checkDependencyDirection, checkConnections, checkMigrationDiff, renderHuman, renderJson, } from "./doctor.js";
45
50
  export { FIXERS, FIXER_CAPABILITIES, fixDependencyDirection, fixDomainToSharedTypeOnly, } from "./doctor/fixers/index.js";
46
51
  export { loadDomain } from "./domain.js";
47
52
  /** `--json` 출력용 구조화 리포트. */
@@ -98,7 +103,7 @@ function renderHelp(version = VERSION) {
98
103
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
99
104
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
100
105
  " gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
101
- " gaon doctor 정적 검사 (13 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선)",
106
+ " gaon doctor 정적 검사 (14 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선)",
102
107
  " gaon doctor --json 자동화용 JSON 출력",
103
108
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
104
109
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -191,7 +196,9 @@ export function runCli(argv, opts = {}) {
191
196
  const hostIdx = argv.indexOf("--host");
192
197
  const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
193
198
  const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
194
- void runServeCommand({ json: argv.includes("--json"), port, host }).catch((err) => {
199
+ // --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식
200
+ // serve 에 넘긴다(결정 69 · dev-only by construction).
201
+ void runServeCommand({ json: argv.includes("--json"), port, host, dev: argv.includes("--dev") }).catch((err) => {
195
202
  const msg = err instanceof Error ? err.message : String(err);
196
203
  process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
197
204
  process.exitCode = 1;
@@ -223,7 +230,7 @@ export function runCli(argv, opts = {}) {
223
230
  });
224
231
  return;
225
232
  }
226
- // `gaon doctor` — 정적 검사(M9-E · 13 검사). --check=<이름>[,<이름>...] 로
233
+ // `gaon doctor` — 정적 검사(M9-E · 14 검사). --check=<이름>[,<이름>...] 로
227
234
  // 선택 실행, --json 은 자동화 파싱용.
228
235
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
229
236
  if (argv[0] === "doctor") {
@@ -346,6 +353,13 @@ export function runCli(argv, opts = {}) {
346
353
  process.exitCode = code;
347
354
  return;
348
355
  }
356
+ if (argv[1] === "ui-kit" || argv[1] === "ui") {
357
+ const appIdx = argv.indexOf("--app");
358
+ const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
359
+ const code = runGenerateUiKitCommand({ app, json: argv.includes("--json") });
360
+ process.exitCode = code;
361
+ return;
362
+ }
349
363
  const known = ["controller", "model", "page", "job", "app"];
350
364
  const type = argv[1];
351
365
  if (type && known.includes(type)) {
@@ -390,7 +404,7 @@ export function runCli(argv, opts = {}) {
390
404
  return;
391
405
  }
392
406
  process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
393
- ` → 현재 지원: gaon g auth | controller | model | page | job | app\n` +
407
+ ` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app\n` +
394
408
  ` → 옵션: --app <이름> · --overwrite · --json\n`);
395
409
  process.exitCode = 1;
396
410
  return;
@@ -0,0 +1,12 @@
1
+ import type { ScaffoldFile } from './controller.js';
2
+ /**
3
+ * 대상 앱의 프론트 배선 파일(main.ts·style.css·index.html)을 web 정본에서
4
+ * 파생해 반환한다. 실제 쓰기는 writeScaffold/writeUiKitFiles 가 담당(멱등).
5
+ */
6
+ export declare function appWiringFiles(app: string, projectName: string): ScaffoldFile[];
7
+ /** 배선 파일 3종의 대상 경로(존재 검사용). */
8
+ export declare function appWiringPaths(app: string): string[];
9
+ /** 대상 앱에 Tailwind 배선(style.css + main.ts 의 style.css import)이 있는지. */
10
+ export declare function hasAppWiring(cwd: string, app: string): boolean;
11
+ /** 프로젝트 package.json 의 name — 배선 title 치환용. 없으면 'app'. */
12
+ export declare function readProjectName(cwd: string): string;
@@ -0,0 +1,68 @@
1
+ // @gaonjs/cli · scaffold · app-wiring — 앱 프론트 배선 (결정 76)
2
+ //
3
+ // 새 앱(`gaon g app admin`)과 web 앱(`gaon new`)이 프론트 배선에서 동등해야
4
+ // 한다: Tailwind CSS 파이프라인(style.css)·Vite 진입(main.ts·index.html).
5
+ // 이 배선이 없으면 그 앱에서 `gaon g ui-kit --app admin` 으로 심은 UI 킷
6
+ // 컴포넌트가 Tailwind 유틸을 못 받아 스타일 없이 렌더된다(결정 74·75).
7
+ //
8
+ // 단일 소스: web 앱의 정본 템플릿(templates/project/apps/web/{main.ts,style.css,
9
+ // index.html}.tpl)을 그대로 읽어 `{{PROJECT_NAME}}` 만 치환하고 대상 앱으로
10
+ // 재타겟한다. style.css 는 앱 무관(토큰만)이라 web 과 바이트 동일하고, main.ts·
11
+ // index.html 은 프로젝트명 title 만 다르다 — 템플릿을 복제하지 않아 드리프트가
12
+ // 없다(결정 76).
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { renderTemplate, templateDir } from '../templates/index.js';
16
+ /** web 정본 템플릿을 대상 앱으로 재타겟하는 배선 파일 3종의 원본 경로. */
17
+ const WIRING = [
18
+ { tpl: 'apps/web/main.ts.tpl', out: 'main.ts' },
19
+ { tpl: 'apps/web/style.css.tpl', out: 'style.css' },
20
+ { tpl: 'apps/web/index.html.tpl', out: 'index.html' },
21
+ ];
22
+ /**
23
+ * 대상 앱의 프론트 배선 파일(main.ts·style.css·index.html)을 web 정본에서
24
+ * 파생해 반환한다. 실제 쓰기는 writeScaffold/writeUiKitFiles 가 담당(멱등).
25
+ */
26
+ export function appWiringFiles(app, projectName) {
27
+ const base = templateDir();
28
+ const tokens = { projectName, gaonjsVersion: readGaonjsVersion() };
29
+ return WIRING.map(({ tpl, out }) => {
30
+ const raw = readFileSync(join(base, tpl), 'utf8');
31
+ // 주석 안 `apps/web/…` 경로 표기를 대상 앱으로 맞춘다(import 는 상대라 무관).
32
+ const retargeted = renderTemplate(raw, tokens).replaceAll('apps/web/', `apps/${app}/`);
33
+ return { path: `apps/${app}/${out}`, contents: retargeted };
34
+ });
35
+ }
36
+ /** 배선 파일 3종의 대상 경로(존재 검사용). */
37
+ export function appWiringPaths(app) {
38
+ return WIRING.map(({ out }) => `apps/${app}/${out}`);
39
+ }
40
+ /** 대상 앱에 Tailwind 배선(style.css + main.ts 의 style.css import)이 있는지. */
41
+ export function hasAppWiring(cwd, app) {
42
+ const styleCss = join(cwd, 'apps', app, 'style.css');
43
+ const mainTs = join(cwd, 'apps', app, 'main.ts');
44
+ if (!existsSync(styleCss) || !existsSync(mainTs))
45
+ return false;
46
+ const css = readFileSync(styleCss, 'utf8');
47
+ const main = readFileSync(mainTs, 'utf8');
48
+ return /@tailwind\b/.test(css) && /['"]\.\/style\.css['"]/.test(main);
49
+ }
50
+ /** 프로젝트 package.json 의 name — 배선 title 치환용. 없으면 'app'. */
51
+ export function readProjectName(cwd) {
52
+ return readPackageField(cwd, (pkg) => (typeof pkg.name === 'string' ? pkg.name : undefined)) ?? 'app';
53
+ }
54
+ /** 설치된 gaonjs 버전(배선 템플릿엔 미사용이나 렌더 계약상 채운다). */
55
+ function readGaonjsVersion() {
56
+ return '';
57
+ }
58
+ function readPackageField(cwd, pick) {
59
+ const pj = join(cwd, 'package.json');
60
+ if (!existsSync(pj))
61
+ return undefined;
62
+ try {
63
+ return pick(JSON.parse(readFileSync(pj, 'utf8')));
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
package/dist/serve.d.ts CHANGED
@@ -5,6 +5,12 @@ export interface ServeCommandOptions {
5
5
  readonly port?: number;
6
6
  /** 리슨 호스트. 우선순위: 옵션 > config.web.host > '0.0.0.0'. */
7
7
  readonly host?: string;
8
+ /**
9
+ * dev 모드(gaon dev 자식). true 면 dev 전용 진단 라우트(/_gaon/health)를
10
+ * 등록한다. 운영 serve 는 이 플래그 없이 실행되어 진단 라우트가 노출되지
11
+ * 않는다(결정 69 · dev-only by construction).
12
+ */
13
+ readonly dev?: boolean;
8
14
  /** 프로세스 시그널(테스트 주입). 기본 process. */
9
15
  readonly signals?: {
10
16
  on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
package/dist/serve.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { loadDotEnv } from '@gaonjs/core';
15
15
  import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
16
16
  import { registerTsResolve } from './tsResolve.js';
17
+ import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
17
18
  function humanEvent(e) {
18
19
  switch (e.kind) {
19
20
  case 'starting': {
@@ -54,6 +55,14 @@ export async function runServeCommand(opts = {}) {
54
55
  const port = opts.port ??
55
56
  config.web?.port ??
56
57
  (process.env.PORT ? Number(process.env.PORT) : 3000);
58
+ // dev 전용 진단 라우트(결정 69). listen 전에 등록한다 — 운영 serve 는
59
+ // opts.dev 가 없어 등록되지 않으므로 /_gaon/health 는 production 에 없다.
60
+ // wired.app 은 여기서 FastifyInstance 로 해상되므로(cli 는 fastify 타입을
61
+ // 직접 의존하지 않는다) 라우트 등록은 serve 층에서 인라인으로 한다.
62
+ if (opts.dev) {
63
+ const healthCtx = { cwd, config, apps: wired.apps, nats: wired.nats, port, host };
64
+ wired.app.get(DEV_HEALTH_PATH, async () => computeHealth(healthCtx));
65
+ }
57
66
  await wired.app.listen({ host, port });
58
67
  const displayHost = host === '0.0.0.0' ? 'localhost' : host;
59
68
  emit({ kind: 'listening', host, port, url: `http://${displayHost}:${port}` });
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, router } from 'gaonjs/vue'
3
+ import Card from '../components/ui/Card.vue'
4
+ import CardHeader from '../components/ui/CardHeader.vue'
5
+ import CardTitle from '../components/ui/CardTitle.vue'
6
+ import CardDescription from '../components/ui/CardDescription.vue'
7
+ import CardContent from '../components/ui/CardContent.vue'
8
+ import CardFooter from '../components/ui/CardFooter.vue'
9
+ import Button from '../components/ui/Button.vue'
3
10
 
4
11
  // dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
5
12
  const { user, csrf } = pageProps<'{{APP_NAME}}:dashboard#show'>()
@@ -14,9 +21,18 @@ function logout(): void {
14
21
  </script>
15
22
 
16
23
  <template>
17
- <main>
18
- <h1>환영합니다, {{ user.name }}님</h1>
19
- <p>{{ user.email }}</p>
20
- <button type="button" @click="logout">로그아웃</button>
21
- </main>
24
+ <div class="mx-auto max-w-2xl px-4 py-10">
25
+ <Card>
26
+ <CardHeader>
27
+ <CardTitle>환영합니다, {{ user.name }}님</CardTitle>
28
+ <CardDescription>{{ user.email }}</CardDescription>
29
+ </CardHeader>
30
+ <CardContent>
31
+ <p class="text-sm text-muted-foreground">보호된 페이지입니다 — this.requireAuth() 로 지킵니다.</p>
32
+ </CardContent>
33
+ <CardFooter>
34
+ <Button variant="outline" @click="logout">로그아웃</Button>
35
+ </CardFooter>
36
+ </Card>
37
+ </div>
22
38
  </template>
@@ -1,5 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, useForm } from 'gaonjs/vue'
3
+ import Card from '../../components/ui/Card.vue'
4
+ import CardHeader from '../../components/ui/CardHeader.vue'
5
+ import CardTitle from '../../components/ui/CardTitle.vue'
6
+ import CardDescription from '../../components/ui/CardDescription.vue'
7
+ import CardContent from '../../components/ui/CardContent.vue'
8
+ import Form from '../../components/ui/Form.vue'
9
+ import FormField from '../../components/ui/FormField.vue'
10
+ import Input from '../../components/ui/Input.vue'
11
+ import Button from '../../components/ui/Button.vue'
12
+ import Alert from '../../components/ui/Alert.vue'
13
+ import AlertDescription from '../../components/ui/AlertDescription.vue'
3
14
 
4
15
  // 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2).
5
16
  const { error, csrf } = pageProps<'{{APP_NAME}}:session#new'>()
@@ -10,12 +21,30 @@ const form = useForm({ email: '', password: '', _csrf: csrf })
10
21
  </script>
11
22
 
12
23
  <template>
13
- <form @submit.prevent="form.post('/session')">
14
- <h1>로그인</h1>
15
- <p v-if="error" class="error">{{ error }}</p>
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>
19
- <a href="/registration/new">회원가입</a>
20
- </form>
24
+ <div class="mx-auto flex min-h-[70vh] max-w-sm items-center px-4">
25
+ <Card class="w-full">
26
+ <CardHeader>
27
+ <CardTitle>로그인</CardTitle>
28
+ <CardDescription>계정으로 로그인하세요.</CardDescription>
29
+ </CardHeader>
30
+ <CardContent>
31
+ <Alert v-if="error" variant="destructive" class="mb-4">
32
+ <AlertDescription>{{ error }}</AlertDescription>
33
+ </Alert>
34
+ <Form @submit="form.post('/session')">
35
+ <FormField label="이메일" :error="form.errors.email">
36
+ <Input v-model="form.email" type="email" required />
37
+ </FormField>
38
+ <FormField label="비밀번호" :error="form.errors.password">
39
+ <Input v-model="form.password" type="password" required />
40
+ </FormField>
41
+ <Button type="submit" class="w-full" :disabled="form.processing">로그인</Button>
42
+ </Form>
43
+ <p class="mt-4 text-center text-sm text-muted-foreground">
44
+ 계정이 없으신가요?
45
+ <a href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</a>
46
+ </p>
47
+ </CardContent>
48
+ </Card>
49
+ </div>
21
50
  </template>
@@ -1,5 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, useForm } from 'gaonjs/vue'
3
+ import Card from '../../components/ui/Card.vue'
4
+ import CardHeader from '../../components/ui/CardHeader.vue'
5
+ import CardTitle from '../../components/ui/CardTitle.vue'
6
+ import CardDescription from '../../components/ui/CardDescription.vue'
7
+ import CardContent from '../../components/ui/CardContent.vue'
8
+ import Form from '../../components/ui/Form.vue'
9
+ import FormField from '../../components/ui/FormField.vue'
10
+ import Input from '../../components/ui/Input.vue'
11
+ import Button from '../../components/ui/Button.vue'
12
+ import Alert from '../../components/ui/Alert.vue'
13
+ import AlertDescription from '../../components/ui/AlertDescription.vue'
3
14
 
4
15
  const { error, csrf } = pageProps<'{{APP_NAME}}:registration#new'>()
5
16
 
@@ -8,13 +19,33 @@ const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
8
19
  </script>
9
20
 
10
21
  <template>
11
- <form @submit.prevent="form.post('/registration')">
12
- <h1>회원가입</h1>
13
- <p v-if="error" class="error">{{ error }}</p>
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>
18
- <a href="/session/new">로그인</a>
19
- </form>
22
+ <div class="mx-auto flex min-h-[70vh] max-w-sm items-center px-4">
23
+ <Card class="w-full">
24
+ <CardHeader>
25
+ <CardTitle>회원가입</CardTitle>
26
+ <CardDescription>새 계정을 만드세요.</CardDescription>
27
+ </CardHeader>
28
+ <CardContent>
29
+ <Alert v-if="error" variant="destructive" class="mb-4">
30
+ <AlertDescription>{{ error }}</AlertDescription>
31
+ </Alert>
32
+ <Form @submit="form.post('/registration')">
33
+ <FormField label="이름" :error="form.errors.name">
34
+ <Input v-model="form.name" required />
35
+ </FormField>
36
+ <FormField label="이메일" :error="form.errors.email">
37
+ <Input v-model="form.email" type="email" required />
38
+ </FormField>
39
+ <FormField label="비밀번호" :error="form.errors.password">
40
+ <Input v-model="form.password" type="password" required />
41
+ </FormField>
42
+ <Button type="submit" class="w-full" :disabled="form.processing">회원가입</Button>
43
+ </Form>
44
+ <p class="mt-4 text-center text-sm text-muted-foreground">
45
+ 이미 계정이 있으신가요?
46
+ <a href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</a>
47
+ </p>
48
+ </CardContent>
49
+ </Card>
50
+ </div>
20
51
  </template>
@@ -5,7 +5,7 @@ import { User } from '../../../domain/models/User.js'
5
5
  export default controller({
6
6
  // GET /registration/new — 회원가입 폼
7
7
  async new() {
8
- return this.render('auth/Signup', { error: null as string | null, csrf: this.csrfToken() })
8
+ return this.render('Auth/Signup', { error: null as string | null, csrf: this.csrfToken() })
9
9
  },
10
10
  // POST /registration — 회원가입
11
11
  async create() {
@@ -5,7 +5,7 @@ import { User } from '../../../domain/models/User.js'
5
5
  export default controller({
6
6
  // GET /session/new — 로그인 폼
7
7
  async new() {
8
- return this.render('auth/Login', { error: null as string | null, csrf: this.csrfToken() })
8
+ return this.render('Auth/Login', { error: null as string | null, csrf: this.csrfToken() })
9
9
  },
10
10
  // POST /session — 로그인
11
11
  async create() {
@@ -16,7 +16,7 @@ export default controller({
16
16
  this.auth.login(user)
17
17
  return this.redirect('/dashboard')
18
18
  }
19
- return this.render('auth/Login', {
19
+ return this.render('Auth/Login', {
20
20
  error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
21
21
  csrf: this.csrfToken(),
22
22
  })
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
104
104
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
105
105
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
106
106
 
107
- ### 2.2 `gaon doctor` 검사 13
107
+ ### 2.2 `gaon doctor` 검사 14
108
108
 
109
109
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
110
110
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -119,6 +119,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
119
119
  11. `model-filename` — 모델 파일명 PascalCase 관례 (결정 32·46 · `--fix` 지원)
120
120
  12. `page-filename` — Vue 페이지 파일명 PascalCase 관례 (결정 32·46)
121
121
  13. `auth-wiring` — requireAuth/this.auth 사용 ↔ `app.config.ts` 인증 배선 (결정 59)
122
+ 14. `ui-kit-wiring` — UI 킷 컴포넌트 import ↔ `apps/<앱>/style.css` Tailwind 배선 (결정 76 · 경고)
122
123
 
123
124
  ## 3. 로직 배치 One Way 판단표
124
125
 
@@ -159,7 +160,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
159
160
  ```bash
160
161
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
161
162
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
162
- gaon doctor # 정적 검사 13종 (§2.2)
163
+ gaon doctor # 정적 검사 14종 (§2.2)
163
164
  ```
164
165
 
165
166
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -36,8 +36,9 @@ Gaon 프레임웍 문서: https://gaonjs.dev
36
36
  9. **실시간은 v1 포함**(§7): 웹서버 ↔ 허브는 TCP 지속 연결 · NATS 는
37
37
  broadcast 전용(errata E-2). 운영 프로세스는 serve·work·hub 3종.
38
38
  10. **인증·폼은 Inertia SPA**(§6 · SSR 아님). 로그인/회원가입은
39
- `this.render('auth/Login')` + `Inertia.post()` → 서버 redirect.
40
- REST + `fetch()` API (JWT) 전용.
39
+ `this.render('Auth/Login')` + `gaonjs/vue` 의 `useForm(...).post()`
40
+ (DELETE 등은 `router`) → 서버 redirect(결정 64). REST + `fetch()`
41
+ 는 API 앱(JWT) 전용.
41
42
  11. **컴포저블·레이아웃**(errata E-5): 컴포저블은 컴포넌트와 대칭
42
43
  (`apps/<앱>/composables/` + `shared/composables/`, `use` 접두사).
43
44
  shared 컴포저블은 인자로만 받는 순수 로직(api·pageProps 금지).
@@ -115,21 +115,29 @@ await OrderPlaced.emit({ orderId: 1n })
115
115
  ### 4. 아웃박스 (트랜잭션 정합)
116
116
 
117
117
  이벤트를 DB 트랜잭션과 **원자적으로** 발행하려면 아웃박스를 쓴다.
118
- `runInTransaction` 안에서 발행한 이벤트는 같은 트랜잭션의 아웃박스
119
- 테이블에 스테이징되고, 트랜잭션이 커밋돼야 릴레이가 실제로 NATS 에
120
- 발행한다. 트랜잭션이 롤백되면 이벤트도 사라진다.
118
+ 트랜잭션 안에서 발행한 이벤트는 같은 트랜잭션의 아웃박스 테이블에
119
+ 스테이징되고, 트랜잭션이 커밋돼야 릴레이가 실제로 NATS 에 발행한다.
120
+ 트랜잭션이 롤백되면 이벤트도 사라진다. `service()` 본문 전체가 한
121
+ 트랜잭션이므로(§서비스), 본문 안 `emit` 이 곧 아웃박스 스테이징이다.
121
122
 
122
123
  ```ts
123
- import { runInTransaction } from 'gaonjs/async'
124
+ // domain/services/placeOrder.ts service() 본문 = 한 트랜잭션.
125
+ import { service } from 'gaonjs/service'
126
+ import { Order } from '../models/Order.js'
127
+ import { OrderPlaced } from '../events/orderPlaced.js'
124
128
 
125
- await runInTransaction(async () => {
126
- await Order.create({ /* … */ })
127
- await OrderPlaced.emit({ orderId }) // 커밋돼야 실제 발행됨
129
+ export const PlaceOrder = service(async (input: { name: string }) => {
130
+ const order = await Order.create(input)
131
+ await OrderPlaced.emit({ orderId: order.id }) // 트랜잭션 안 → 아웃박스 스테이징 · 커밋돼야 발행
132
+ return order
128
133
  })
129
134
  ```
130
135
 
131
136
  - 트랜잭션 안의 `emit` 은 `AsyncLocalStorage` 로 투명하게 감지돼
132
137
  아웃박스에 스테이징된다(별도 API 호출 불필요).
138
+ - 저수준 원시는 `runInTransaction(db, fn)`(커넥션을 넘긴다) — `service()`
139
+ 가 내부에서 이를 쓴다. 대부분의 도메인 코드는 `service()` 본문에서
140
+ emit 하거나, "커밋 후 즉시 발행"이면 `afterCommit(fn)`(§서비스)을 쓴다.
133
141
  - 릴레이(`gaon work` 내장)가 `SKIP LOCKED` 로 아웃박스를 폴링해 발행
134
142
  한다 (기본 폴 1000ms · 배치 100).
135
143
  - at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
@@ -151,6 +151,78 @@ Nuxt 식 자동 import 는 넣지 않는다. 모든 컴포넌트·컴포저블
151
151
  import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
152
152
  설정을 잡는다.
153
153
 
154
+ ### 7. 랜딩·라이브 헬스 (결정 69 · 70)
155
+
156
+ `gaon new` 첫 화면(`apps/web/pages/Home/Index.vue`)은 라이브 상태 랜딩이다 — 다크
157
+ 헤더 레이아웃(`layouts/Default.vue`) + 실 상태 카드(WEB·DATABASE·HUB·DOCTOR) + 동적
158
+ 다음 단계 + 실 `routes.ts` 코드 블록. 상태는 **하드코딩하지 않는다** — dev 전용
159
+ 엔드포인트 `/_gaon/health` 를 컴포저블(`composables/useGaonHealth.ts`)로 읽어 바인딩한다.
160
+ 이 엔드포인트는 `gaon dev` 에서만 등록되고(운영 빌드엔 없음), 404 면 컴포저블이
161
+ `available=false` 로 우아하게 degrade 한다.
162
+
163
+ - **HUB 카드는 실 introspection(결정 71)** — jobs = JetStream 스트림 대기(pending)
164
+ 수 · channels = 프레즌스 KV 권위의 채널별 실 접속자 수. 조회 실패·미접속을 0/true
165
+ 로 가장하지 않는다(과대 약속 금지) — 버킷·스트림 미생성이면 각각 null 로 degrade.
166
+ - **스타일은 Tailwind 유틸 + UI 킷(결정 74·75)** — 스캐폴드는 Tailwind
167
+ (`tailwind.config.ts`·`postcss.config.js`·`apps/<앱>/style.css`)를 편입한다.
168
+ 랜딩·auth 페이지는 Tailwind 유틸과 UI 킷 컴포넌트(§8)로 그린다(결정 69 의
169
+ scoped-CSS 미편입 방침은 결정 74 로 뒤집혔다).
170
+ - **auth 링크는 수동(결정 70)** — `gaon g auth` 는 auth 페이지·라우트만 신설하고
171
+ 랜딩·레이아웃 nav 를 편집하지 않는다. 헤더에 로그인 링크를 두려면 `Default.vue`
172
+ 의 nav 에 `<a href="/session/new">로그인</a>` 을 직접 추가한다(Rails 관례).
173
+ - **auth 페이지 경로 = `pages/Auth/`(PascalCase)** — `gaon g auth` 는 `Auth/Login.vue`
174
+ ·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
175
+ 소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
176
+
177
+ ### 8. UI 킷 (결정 74·75 · shadcn 참조 · 복사-소유)
178
+
179
+ `gaon new` 는 기본 앱에 UI 킷을 심고(배터리 포함), `gaon g ui-kit [--app <앱>]`
180
+ 로 다른 앱에도 추가한다. 컴포넌트는 **프로젝트에 복사된 여러분 코드**다 — npm
181
+ 의존이 아니라 파일이므로 자유롭게 고친다(shadcn 방식). 외부 런타임 의존이 없다
182
+ (clsx·tailwind-merge·cva·reka-ui 미도입 · `cn` 은 자작 · Dialog/Sheet 는 Teleport+
183
+ Transition 자작).
184
+
185
+ 배치·import:
186
+
187
+ ```
188
+ apps/<앱>/lib/utils.ts # cn() — 조건부 클래스 병합
189
+ apps/<앱>/components/ui/*.vue # Button·Input·Label·Badge·Card…·Alert…·Form…·Dialog·Sheet
190
+ ```
191
+
192
+ ```vue
193
+ <script setup lang="ts">
194
+ import Button from '../../components/ui/Button.vue' // 앱 로컬 · 상대 경로
195
+ import Card from '../../components/ui/Card.vue'
196
+ import CardContent from '../../components/ui/CardContent.vue'
197
+ </script>
198
+
199
+ <template>
200
+ <Card>
201
+ <CardContent>
202
+ <Button variant="default">저장</Button> <!-- variant·size 로 모양 선택 -->
203
+ </CardContent>
204
+ </Card>
205
+ </template>
206
+ ```
207
+
208
+ - **폼은 UI 킷 Form + gaonjs `useForm`(결정 64)** — `Form` 은 얇은 `<form>` 래퍼로
209
+ `@submit` 을 `useForm` 의 `post/put/delete` 로 넘긴다. vee-validate 를 끌어오지
210
+ 않는다(검증·상태는 `useForm`). `FormField label error` + `FormMessage` 로 라벨·
211
+ 오류를 붙이고, `:error="form.errors.<field>"` 로 서버 검증을 표시한다.
212
+ - **`class` 는 폴스루로 병합** — 단일 루트 컴포넌트는 `<Button class="w-full">` 처럼
213
+ 넘긴 클래스가 루트로 흘러간다(별도 `class` prop 선언 없음). `cn` 은 충돌 클래스
214
+ 자동 해소를 하지 않는다 — 오버라이드가 잦으면 tailwind-merge 를 설치해 `cn` 만 교체.
215
+ - **디자인 토큰은 `style.css` 한 곳(결정 74)** — 컴포넌트는 `bg-primary`·
216
+ `text-muted-foreground` 같은 의미 토큰만 쓰고, 실색은 `apps/<앱>/style.css` 의
217
+ `:root`/`.dark` CSS 변수에서 바꾼다(다크 모드 = `<html class="dark">`).
218
+ - **멀티앱은 앱마다 배선이 따로다(결정 76)** — `gaon g app admin` 은 새 앱에
219
+ 프론트 배선(`apps/admin/{style.css,main.ts,index.html}`)을 web 앱과 동등하게
220
+ 함께 심는다. 그래서 `gaon g ui-kit --app admin` 으로 심은 UI 킷이 Tailwind
221
+ 유틸을 그대로 받는다(`g ui-kit --app` 은 배선이 없으면 멱등으로 보정한다).
222
+ `tailwind.config.ts`·`postcss.config.js` 는 프로젝트 루트 공유(앱마다 두지
223
+ 않는다). 앱이 UI 킷을 import 하는데 그 앱에 `style.css` 배선이 없으면 doctor
224
+ **ui-kit-wiring** 이 경고한다.
225
+
154
226
  ## 정본 예시
155
227
 
156
228
  ```vue
@@ -186,7 +258,7 @@ async function runSearch(q: string) {
186
258
  - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출 금지** — doctor
187
259
  shared-composable-purity 위반. 데이터는 props/인자로.
188
260
  - **Vue 페이지에서 `fetch()` 로 폼 구현 금지** — 세션 앱 폼은
189
- `Inertia.post()` (`agents/web.md` §4).
261
+ `gaonjs/vue` 의 `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
190
262
  - **레이아웃을 shared 에 두지 않는다** — 앱별이 정상.
191
263
  - **페이지 파일명은 PascalCase** — `pages/Posts/Index.vue`(폴더 세그먼트도
192
264
  Route 이름). 소문자(`posts/index.vue`)는 doctor **page-filename** 이 잡는다
@@ -203,4 +275,10 @@ async function runSearch(q: string) {
203
275
  | 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
204
276
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
205
277
  | 결정 46 | doctor page-filename(페이지 PascalCase)·model/column 검사 3종 |
278
+ | 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 · 실 상태 · 코드 블록) |
279
+ | 결정 70 | auth 통합 = 수동(`gaon g auth` 는 랜딩·nav 를 안 건드림 · Rails 관례) |
280
+ | 결정 71 | HUB 카드 실 introspection(jobs=스트림 pending · channels=프레즌스 실 접속자 · 과대 약속 금지) |
281
+ | 결정 74 | Tailwind 스캐폴드 편입(tailwind.config.ts·postcss.config.js·style.css · 결정 69 scoped-CSS 방침 뒤집기) |
282
+ | 결정 75 | shadcn 식 UI 킷(`gaon g ui-kit` · 복사-소유 · Vue 3 신작 · 외부 런타임 의존 0) |
283
+ | 결정 76 | 멀티앱 UI 킷 배선 자동화(`g app`·`g ui-kit --app` 이 앱별 Tailwind 배선 동봉·멱등 보정 · doctor ui-kit-wiring) |
206
284
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |