@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
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,6 +34,7 @@ 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";
@@ -98,10 +100,11 @@ function renderHelp(version = VERSION) {
98
100
  " gaon dev --json 통합 콘솔을 JSON 라인으로 출력(자동화)",
99
101
  " gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
100
102
  " gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
103
+ " gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
101
104
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
102
105
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
103
106
  " gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
104
- " gaon doctor 정적 검사 (13 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선)",
107
+ " gaon doctor 정적 검사 (17 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method)",
105
108
  " gaon doctor --json 자동화용 JSON 출력",
106
109
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
107
110
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -192,11 +195,15 @@ export function runCli(argv, opts = {}) {
192
195
  if (argv[0] === "serve") {
193
196
  const portIdx = argv.indexOf("--port");
194
197
  const hostIdx = argv.indexOf("--host");
198
+ const workersIdx = argv.indexOf("--workers");
195
199
  const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
196
200
  const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
201
+ // --workers <n|auto>: node:cluster 다중화(결정 84). env WEB_CONCURRENCY 도 가능.
202
+ const workersRaw = workersIdx >= 0 ? argv[workersIdx + 1] : undefined;
203
+ const workers = workersRaw === "auto" ? "auto" : workersRaw !== undefined ? Number(workersRaw) : undefined;
197
204
  // --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식
198
205
  // serve 에 넘긴다(결정 69 · dev-only by construction).
199
- void runServeCommand({ json: argv.includes("--json"), port, host, dev: argv.includes("--dev") }).catch((err) => {
206
+ void runServeCommand({ json: argv.includes("--json"), port, host, workers, dev: argv.includes("--dev") }).catch((err) => {
200
207
  const msg = err instanceof Error ? err.message : String(err);
201
208
  process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
202
209
  process.exitCode = 1;
@@ -228,7 +235,7 @@ export function runCli(argv, opts = {}) {
228
235
  });
229
236
  return;
230
237
  }
231
- // `gaon doctor` — 정적 검사(M9-E · 13 검사). --check=<이름>[,<이름>...] 로
238
+ // `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
232
239
  // 선택 실행, --json 은 자동화 파싱용.
233
240
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
234
241
  if (argv[0] === "doctor") {
@@ -351,6 +358,13 @@ export function runCli(argv, opts = {}) {
351
358
  process.exitCode = code;
352
359
  return;
353
360
  }
361
+ if (argv[1] === "ui-kit" || argv[1] === "ui") {
362
+ const appIdx = argv.indexOf("--app");
363
+ const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
364
+ const code = runGenerateUiKitCommand({ app, json: argv.includes("--json") });
365
+ process.exitCode = code;
366
+ return;
367
+ }
354
368
  const known = ["controller", "model", "page", "job", "app"];
355
369
  const type = argv[1];
356
370
  if (type && known.includes(type)) {
@@ -395,7 +409,7 @@ export function runCli(argv, opts = {}) {
395
409
  return;
396
410
  }
397
411
  process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
398
- ` → 현재 지원: gaon g auth | controller | model | page | job | app\n` +
412
+ ` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app\n` +
399
413
  ` → 옵션: --app <이름> · --overwrite · --json\n`);
400
414
  process.exitCode = 1;
401
415
  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
@@ -1,3 +1,8 @@
1
+ /**
2
+ * 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
3
+ * (availableParallelism). 0·음수·비수치는 1 로 떨어진다(안전 기본). 결정 84.
4
+ */
5
+ export declare function resolveWorkerCount(workers: number | 'auto' | undefined, env?: NodeJS.ProcessEnv): number;
1
6
  export interface ServeCommandOptions {
2
7
  readonly cwd?: string;
3
8
  readonly json?: boolean;
@@ -5,6 +10,11 @@ export interface ServeCommandOptions {
5
10
  readonly port?: number;
6
11
  /** 리슨 호스트. 우선순위: 옵션 > config.web.host > '0.0.0.0'. */
7
12
  readonly host?: string;
13
+ /**
14
+ * 워커 수(node:cluster). 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수.
15
+ * 2 이상이면 프라이머리가 fork 해 다중화한다(결정 84).
16
+ */
17
+ readonly workers?: number | 'auto';
8
18
  /**
9
19
  * dev 모드(gaon dev 자식). true 면 dev 전용 진단 라우트(/_gaon/health)를
10
20
  * 등록한다. 운영 serve 는 이 플래그 없이 실행되어 진단 라우트가 노출되지
package/dist/serve.js CHANGED
@@ -8,13 +8,32 @@
8
8
  * env 를 먼저 로드한다 — .env 의 값이 gaon.config.ts 안의 env('KEY') 에
9
9
  * 들어갈 수 있어야 하기 때문.
10
10
  *
11
- * 워커 (--workers, WEB_CONCURRENCY)는 v0.8 정본이지만 컨테이너 안 기본
12
- * 1워커라 M9-A 단일 프로세스만 다룬다. node:cluster 통합은 후속(M9-C).
11
+ * 워커 다중화(--workers · WEB_CONCURRENCY)는 node:cluster 처리한다(결정 84):
12
+ * 기본 1(컨테이너 기본). 2 이상이면 프라이머리가 N 워커를 fork 하고 OS 가
13
+ * 연결을 분산(cluster 라운드로빈)한다. 워커가 예기치 않게 죽으면 교체 fork,
14
+ * SIGTERM/SIGINT 에 워커들을 graceful drain 후 종료한다.
13
15
  */
16
+ import cluster from 'node:cluster';
17
+ import { availableParallelism } from 'node:os';
14
18
  import { loadDotEnv } from '@gaonjs/core';
15
19
  import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
16
20
  import { registerTsResolve } from './tsResolve.js';
17
21
  import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
22
+ /**
23
+ * 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
24
+ * (availableParallelism). 0·음수·비수치는 1 로 떨어진다(안전 기본). 결정 84.
25
+ */
26
+ export function resolveWorkerCount(workers, env = process.env) {
27
+ const raw = workers ?? env.WEB_CONCURRENCY;
28
+ if (raw === undefined || raw === '')
29
+ return 1;
30
+ if (raw === 'auto')
31
+ return Math.max(1, availableParallelism());
32
+ const n = typeof raw === 'number' ? raw : Number(raw);
33
+ if (!Number.isFinite(n) || n <= 0)
34
+ return 1;
35
+ return Math.floor(n);
36
+ }
18
37
  function humanEvent(e) {
19
38
  switch (e.kind) {
20
39
  case 'starting': {
@@ -24,17 +43,81 @@ function humanEvent(e) {
24
43
  }
25
44
  case 'listening':
26
45
  return ` ▶ 리슨 중 — ${e.url} (Ctrl+C 로 종료)`;
46
+ case 'cluster':
47
+ return ` gaon serve · 클러스터 — 워커 ${e.workers}개 fork (node:cluster)`;
48
+ case 'worker-exit':
49
+ return ` ⚠ 워커 종료(pid ${e.pid ?? '?'} · code ${e.code}${e.signal ? ` · ${e.signal}` : ''})${e.restarted ? ' — 교체 fork' : ''}`;
27
50
  case 'stopping':
28
51
  return ' gaon serve · 종료 중 (graceful) ...';
29
52
  case 'stopped':
30
53
  return ' gaon serve · 종료';
31
54
  }
32
55
  }
56
+ /**
57
+ * 클러스터 프라이머리 — N 워커를 fork 하고 감독한다. 워커가 예기치 않게 죽으면
58
+ * 교체 fork(복원력), SIGTERM/SIGINT 에 워커들을 SIGTERM 으로 graceful drain 한 뒤
59
+ * (상한 `GAON_WORKER_DRAIN_MS`, 기본 30s) SIGKILL 로 강제 종료한다. 결정 84.
60
+ */
61
+ async function runClusterPrimary(workerCount, opts) {
62
+ const json = opts.json ?? false;
63
+ const signals = opts.signals ?? process;
64
+ const drainMs = process.env.GAON_WORKER_DRAIN_MS ? Number(process.env.GAON_WORKER_DRAIN_MS) : 30_000;
65
+ const emit = (e) => {
66
+ if (json)
67
+ process.stdout.write(JSON.stringify(e) + '\n');
68
+ else
69
+ process.stdout.write(humanEvent(e) + '\n');
70
+ };
71
+ emit({ kind: 'cluster', workers: workerCount });
72
+ let shuttingDown = false;
73
+ for (let i = 0; i < workerCount; i++)
74
+ cluster.fork();
75
+ cluster.on('exit', (worker, code, signal) => {
76
+ if (shuttingDown)
77
+ return;
78
+ // 예기치 않은 종료 → 교체 fork 로 워커 수를 유지한다.
79
+ emit({ kind: 'worker-exit', pid: worker.process.pid, code, signal, restarted: true });
80
+ cluster.fork();
81
+ });
82
+ await new Promise((resolvePromise) => {
83
+ const stop = () => {
84
+ if (shuttingDown)
85
+ return;
86
+ shuttingDown = true;
87
+ signals.off('SIGINT', stop);
88
+ signals.off('SIGTERM', stop);
89
+ emit({ kind: 'stopping' });
90
+ for (const w of Object.values(cluster.workers ?? {}))
91
+ w?.kill('SIGTERM');
92
+ const killTimer = setTimeout(() => {
93
+ for (const w of Object.values(cluster.workers ?? {}))
94
+ w?.kill('SIGKILL');
95
+ }, drainMs);
96
+ const check = () => {
97
+ if (Object.keys(cluster.workers ?? {}).length === 0) {
98
+ clearTimeout(killTimer);
99
+ emit({ kind: 'stopped' });
100
+ resolvePromise();
101
+ }
102
+ };
103
+ cluster.on('exit', check);
104
+ check();
105
+ };
106
+ signals.on('SIGINT', stop);
107
+ signals.on('SIGTERM', stop);
108
+ });
109
+ }
33
110
  /**
34
111
  * `gaon serve` 진입점. loadDotEnv → loadGaonConfig → wireGaon → listen →
35
112
  * SIGINT 대기 → graceful close. 예외는 stderr + exit 1.
36
113
  */
37
114
  export async function runServeCommand(opts = {}) {
115
+ // 워커 다중화(결정 84): 2 이상이고 이 프로세스가 프라이머리면 감독만 한다.
116
+ // 워커(cluster.isWorker)와 단일 프로세스(count=1)는 아래 서버 본문을 실행한다.
117
+ const workerCount = resolveWorkerCount(opts.workers);
118
+ if (workerCount > 1 && cluster.isPrimary) {
119
+ return runClusterPrimary(workerCount, opts);
120
+ }
38
121
  const cwd = opts.cwd ?? process.cwd();
39
122
  const json = opts.json ?? false;
40
123
  const signals = opts.signals ?? process;
@@ -85,4 +168,9 @@ export async function runServeCommand(opts = {}) {
85
168
  signals.on('SIGINT', stop);
86
169
  signals.on('SIGTERM', stop);
87
170
  });
171
+ // 클러스터 워커는 graceful close 후에도 cluster IPC 채널이 이벤트 루프를 잡아
172
+ // 프로세스가 안 죽는다 → 프라이머리가 워커 소멸을 감지 못 해 매달린다. close 가
173
+ // 끝난 뒤 명시적으로 종료한다(안전). 단일 프로세스는 자연 종료(호출 안 함). 결정 84.
174
+ if (cluster.isWorker)
175
+ process.exit(0);
88
176
  }
@@ -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>
@@ -0,0 +1,12 @@
1
+ # 컨테이너 빌드에서 제외 — 호스트 산출물이 이미지로 새지 않게 한다.
2
+ # node_modules 는 반드시 제외: 호스트(예: darwin/arm64) 네이티브 바이너리가
3
+ # 리눅스 컨테이너로 복사되면 esbuild 등이 깨진다(이미지 안에서 재설치).
4
+ node_modules
5
+ **/node_modules
6
+ .gaon
7
+ dist
8
+ .git
9
+ .env
10
+ .env.*
11
+ npm-debug.log*
12
+ *.log
@@ -2,8 +2,8 @@
2
2
 
3
3
  이 문서는 **AI 코딩 에이전트**(Claude · Codex · Cursor · Copilot 등)와
4
4
  사람 개발자가 Gaon 프로젝트에서 작업할 때 참조하는 관례의 진입점이다.
5
- 정본은 설계 문서(v0.15 동결 + errata E-1~E-5, v0.16 편입)이며, 관례
6
- 문서는 **2층 구조**다 (결정 40):
5
+ 정본은 설계 문서(`docs/gaondesignv0.17.md` · v1.0 출시 기준 스냅샷 ·
6
+ v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구조**다 (결정 40):
7
7
 
8
8
  - **이 파일 (코어)** — 절대 규칙 · 로직 배치 판단표 · 검증 루프 ·
9
9
  카테고리 색인. 여기엔 요약만 있다.
@@ -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` 검사 17
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,10 @@ 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 · 경고)
123
+ 15. `route-registration` — 고아 컨트롤러(파일은 있는데 `routes.ts` 미참조 · 도달 불가) (결정 79 · 경고)
124
+ 16. `static-collision` — 정적 파일(`apps/<앱>/static/`)이 라우트/에셋에 가려져 도달 불가 (결정 85 · 경고)
125
+ 17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
122
126
 
123
127
  ## 3. 로직 배치 One Way 판단표
124
128
 
@@ -159,7 +163,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
159
163
  ```bash
160
164
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
161
165
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
162
- gaon doctor # 정적 검사 13종 (§2.2)
166
+ gaon doctor # 정적 검사 17종 (§2.2)
163
167
  ```
164
168
 
165
169
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -209,7 +213,8 @@ gaon doctor # 정적 검사 13종 (§2.2)
209
213
 
210
214
  ## 7. 참고 문서
211
215
 
212
- - 설계 정본: `docs/gaondesignv0.15.md` (동결) + errata E-1~E-5
216
+ - 설계 정본: `docs/gaondesignv0.17.md` (v1.0 출시 기준 스냅샷 · 결정 31~89) ·
217
+ 이력 동결 = `gaondesignv0.16.md`·`v0.15.md` + errata E-1~E-5
213
218
  (E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
214
219
  체이닝 · E-5 컴포저블·레이아웃).
215
220
  - 가이드: `docs/guides/*.md` (getting-started · data · data-flow ·
@@ -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 금지).
@@ -84,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
84
85
 
85
86
  ```bash
86
87
  gaon check # .gaon 재생성 후 타입 검사 (CI 정합)
87
- gaon doctor # 정적 검사 5종 (응답·N+1·의존·커넥션·마이그)
88
+ gaon doctor # 정적 검사 17종 (응답·N+1·의존·커넥션·마이그·순수·자동import·파일명/컬럼·인증·UI킷·라우트 · 상세 AGENTS §2.2)
88
89
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
89
90
  ```
90
91
 
@@ -0,0 +1,30 @@
1
+ # {{PROJECT_NAME}} 운영 이미지 — Node 22 · pnpm · TS 네이티브(gaon serve)
2
+ #
3
+ # gaonjs 는 TS 네이티브(Node 타입 스트리핑)라 별도 tsc 빌드가 없다. `vite build`
4
+ # 로 프론트 번들만 만들고, 런타임은 소스 + 번들을 `gaon serve` 로 그대로 돌린다.
5
+ # 웹·워커·허브 프로세스는 compose.prod.yaml 이 같은 이미지로 command 만 바꿔 띄운다.
6
+
7
+ FROM node:22-slim AS base
8
+ ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
9
+ RUN corepack enable
10
+ WORKDIR /app
11
+
12
+ # 1) 의존 설치 — lockfile 로 재현 가능하게(빌드에 dev 의존 필요).
13
+ FROM base AS deps
14
+ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
15
+ RUN pnpm install --frozen-lockfile
16
+
17
+ # 2) 프론트 번들 빌드(vite build).
18
+ FROM base AS build
19
+ COPY --from=deps /app/node_modules ./node_modules
20
+ COPY . .
21
+ RUN pnpm build
22
+
23
+ # 3) 런타임 — 소스 + 번들 + 의존을 그대로 실행.
24
+ FROM base AS runtime
25
+ ENV NODE_ENV=production
26
+ COPY --from=build /app ./
27
+ # 웹 서버 포트(gaon.config.ts 의 web.port · 기본 3000). 프록시 뒤에 둔다.
28
+ EXPOSE 3000
29
+ # 웹 프로세스. 워커(gaon work)·허브(gaon hub)는 compose.prod.yaml 의 별도 서비스.
30
+ CMD ["pnpm", "serve"]
@@ -115,26 +115,38 @@ 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)이
136
144
  흡수한다.
137
145
  - 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 워커 기동 시 보장된다.
146
+ - 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
147
+ (결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `gaon work` 의
148
+ `outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(운영 상세는
149
+ `docs/guides/operations.md`). 미발행 행은 절대 삭제되지 않는다.
138
150
 
139
151
  ### 5. 스케줄러
140
152