@gaonjs/cli 0.10.2 → 0.13.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 (32) hide show
  1. package/dist/commands/check.js +10 -8
  2. package/dist/doctor/auth-wiring.d.ts +9 -0
  3. package/dist/doctor/auth-wiring.js +93 -0
  4. package/dist/doctor/response-mixing.d.ts +1 -1
  5. package/dist/doctor/response-mixing.js +20 -4
  6. package/dist/doctor/types.d.ts +1 -1
  7. package/dist/doctor.d.ts +1 -0
  8. package/dist/doctor.js +7 -2
  9. package/dist/generate.d.ts +2 -0
  10. package/dist/generate.js +29 -7
  11. package/dist/index.js +7 -6
  12. package/dist/mcp/tools.d.ts +24 -2
  13. package/dist/mcp/tools.js +166 -2
  14. package/dist/scaffold/app.js +2 -2
  15. package/dist/scaffold/page.js +4 -2
  16. package/dist/templates/auth/Dashboard.vue.tpl +1 -1
  17. package/dist/templates/auth/Login.vue.tpl +1 -1
  18. package/dist/templates/auth/Signup.vue.tpl +1 -1
  19. package/dist/templates/auth/app.config.ts.tpl +11 -0
  20. package/dist/templates/auth/dashboard.controller.ts.tpl +1 -1
  21. package/dist/templates/auth/registration.controller.ts.tpl +1 -1
  22. package/dist/templates/auth/routes.ts.tpl +1 -1
  23. package/dist/templates/auth/session.controller.ts.tpl +1 -1
  24. package/dist/templates/project/AGENTS.md.tpl +4 -3
  25. package/dist/templates/project/agents/data.md.tpl +5 -1
  26. package/dist/templates/project/agents/frontend.md.tpl +9 -8
  27. package/dist/templates/project/agents/testing.md.tpl +4 -2
  28. package/dist/templates/project/agents/web.md.tpl +72 -6
  29. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +1 -1
  30. package/dist/templates/project/package.json.tpl +1 -0
  31. package/dist/templates/project/tsconfig.json.tpl +5 -3
  32. package/package.json +7 -7
@@ -145,16 +145,18 @@ async function runStep(step, cwd) {
145
145
  output,
146
146
  };
147
147
  }
148
- // build — 사용자 스크립트가 없으면 pnpm -r build 최선의 폴백으로 시도.
149
- const cmd = 'pnpm';
150
- const args = ['-r', 'run', 'build'];
151
- const { exitCode, output } = await runSubprocess(cwd, cmd, args);
148
+ // build — 사용자 `build` 스크립트가 있으면 hasScript 분기가 이미 처리한다.
149
+ // 여기에 도달했다는 건 build 스크립트가 없다는 뜻 — typecheck·vue-tsc 와
150
+ // 같은 관례로 스킵한다. 과거엔 폴백으로 `pnpm -r run build` 를 돌렸지만,
151
+ // gaon new 프로젝트는 build 스크립트가 없고 packages/ 비어 있어 항상
152
+ // ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT 로 실패했다(= 갓 만든 프로젝트에서
153
+ // gaon check 가 무조건 exit 1). 타입 정합은 typecheck·vue-tsc 가 이미
154
+ // 담보하므로, 빌드는 사용자가 명시적으로 정의했을 때만 검사한다.
152
155
  return {
153
156
  step,
154
- status: exitCode === 0 ? 'passed' : 'failed',
155
- command: `${cmd} ${args.join(' ')}`,
156
- exitCode,
157
- output,
157
+ status: 'skipped',
158
+ output: 'build 스크립트가 없어 건너뜁니다(.vue/타입 정합은 typecheck·vue-tsc 가 담보).\n' +
159
+ '→ 프론트 프로덕션 빌드를 검사에 포함하려면 package.json 에 "build": "vite build" 를 정의하세요.',
158
160
  };
159
161
  }
160
162
  /** doctor 는 이미 있는 명령을 재사용 — cwd 만 넘긴다. json 은 상위에서. */
@@ -0,0 +1,9 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 컨트롤러 소스가 인증 표면을 쓰는지 판정한다(단위 테스트 진입점). */
3
+ export declare function usesAuthSurface(source: string): boolean;
4
+ /** app.config.ts 소스에 auth 배선이 있는지(얕은 텍스트 판정). */
5
+ export declare function hasAuthWiring(source: string): boolean;
6
+ /** app.config.ts 소스에 session 배선이 있는지(얕은 텍스트 판정). */
7
+ export declare function hasSessionWiring(source: string): boolean;
8
+ /** apps/ 를 훑어 인증 배선 누락을 낸다. */
9
+ export declare function checkAuthWiring(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,93 @@
1
+ // @gaonjs/cli · doctor · 인증 배선 검사 (결정 59)
2
+ //
3
+ // 컨트롤러가 인증 표면(this.requireAuth() · this.auth.login() · this.currentUser)
4
+ // 을 쓰는데 app.config.ts 에 auth(loadUser) 배선이 없으면, 컴파일은 통과하지만
5
+ // 표준 부팅(gaon serve = wireGaon)에서 currentUser 가 **영구 null** 이 된다 —
6
+ // 로그인 직후에도 requireAuth 가 계속 리다이렉트하는 런타임 파손이다. 이 검사가
7
+ // 그 상태를 check 시점에 잡는다.
8
+ //
9
+ // 판정은 소스 텍스트 기반(가벼운 정적 검사) — app.config.ts 를 실행하지 않는다.
10
+ // auth 앱이 JWT(strategy:'jwt')여도 auth: 키가 있으면 배선으로 인정한다.
11
+ import { readdir, readFile } from 'node:fs/promises';
12
+ import { existsSync } from 'node:fs';
13
+ import { join, relative } from 'node:path';
14
+ /** 컨트롤러 소스가 인증 표면을 쓰는지 판정한다(단위 테스트 진입점). */
15
+ export function usesAuthSurface(source) {
16
+ return /this\.requireAuth\s*\(|this\.auth\.login\s*\(|this\.currentUser\b/.test(source);
17
+ }
18
+ /** app.config.ts 소스에 auth 배선이 있는지(얕은 텍스트 판정). */
19
+ export function hasAuthWiring(source) {
20
+ return /\bauth\s*:/.test(source);
21
+ }
22
+ /** app.config.ts 소스에 session 배선이 있는지(얕은 텍스트 판정). */
23
+ export function hasSessionWiring(source) {
24
+ return /\bsession\s*:/.test(source);
25
+ }
26
+ /** apps/ 를 훑어 인증 배선 누락을 낸다. */
27
+ export async function checkAuthWiring(cwd) {
28
+ const appsDir = join(cwd, 'apps');
29
+ const issues = [];
30
+ for (const app of await safeListDirs(appsDir)) {
31
+ const ctrlDir = join(appsDir, app, 'controllers');
32
+ const authFiles = [];
33
+ for (const file of await safeListFiles(ctrlDir)) {
34
+ if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
35
+ continue;
36
+ const source = await readFile(join(ctrlDir, file), 'utf8');
37
+ if (usesAuthSurface(source))
38
+ authFiles.push(relative(cwd, join(ctrlDir, file)));
39
+ }
40
+ if (authFiles.length === 0)
41
+ continue;
42
+ const acPath = join(appsDir, app, 'app.config.ts');
43
+ const acRel = relative(cwd, acPath);
44
+ const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : undefined;
45
+ const jwt = acSource != null && /strategy\s*:\s*['"]jwt['"]/.test(acSource);
46
+ if (acSource == null || !hasAuthWiring(acSource)) {
47
+ issues.push({
48
+ rule: 'auth-wiring',
49
+ level: 'error',
50
+ file: authFiles[0],
51
+ message: `인증 배선 누락: apps/${app} 컨트롤러(${authFiles.join(', ')})가 requireAuth/this.auth 를 쓰는데 ` +
52
+ `${acRel} 에 auth 배선이 없습니다. 이대로면 로그인해도 currentUser 가 항상 null 입니다(결정 59).\n` +
53
+ `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
54
+ ` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' },\n` +
55
+ ` auth: { loadUser, loginRedirect: '/session/new' } // import { loadUser } from './auth.js'\n` +
56
+ `→ loadUser 가 없으면 \`gaon g auth --app ${app}\` 스캐폴드가 apps/${app}/auth.ts 에 만들어 줍니다.`,
57
+ detail: { app, controllers: authFiles },
58
+ });
59
+ }
60
+ else if (!jwt && !hasSessionWiring(acSource)) {
61
+ // auth 는 있는데 세션이 없다 — 세션 전략에서는 로그인 자체가 저장될 곳이 없다.
62
+ issues.push({
63
+ rule: 'auth-wiring',
64
+ level: 'error',
65
+ file: acRel,
66
+ message: `세션 배선 누락: ${acRel} 에 auth 는 있는데 session 이 없습니다 — 세션 전략에서는 ` +
67
+ `this.auth.login() 이 저장될 세션이 없어 로그인이 유지되지 않습니다(결정 59).\n` +
68
+ `→ defineAppConfig({...}) 에 session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' } 을 추가하고,\n` +
69
+ `→ 루트 gaon.config.ts 에 redis: { url: process.env.REDIS_URL } 이 켜져 있는지 확인하세요.`,
70
+ detail: { app },
71
+ });
72
+ }
73
+ }
74
+ return { rule: 'auth-wiring', issues };
75
+ }
76
+ async function safeListDirs(dir) {
77
+ try {
78
+ const entries = await readdir(dir, { withFileTypes: true });
79
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
85
+ async function safeListFiles(dir) {
86
+ try {
87
+ const entries = await readdir(dir, { withFileTypes: true });
88
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
@@ -1,5 +1,5 @@
1
1
  import type { RuleReport } from './types.js';
2
- export type ResponseKind = 'render' | 'redirect' | 'json' | 'plain';
2
+ export type ResponseKind = 'render' | 'redirect' | 'json' | 'guard' | 'plain';
3
3
  export interface ActionUsage {
4
4
  readonly file: string;
5
5
  readonly action: string;
@@ -6,10 +6,13 @@
6
6
  //
7
7
  // 정적 분석: apps/<app>/controllers/*.ts 를 TS AST 로 파싱, 액션마다
8
8
  // return 노드에서 최종 응답 형태를 분류(render | redirect | json | plain).
9
- // 서로 다른 형태가 두 종류 이상이면 위반(error 등급).
9
+ // 서로 다른 형태가 두 종류 이상이면 위반(error 등급). 단 {render, redirect}
10
+ // 조합만은 폼 액션 정본(실패 render + 성공 redirect · 결정 57 보완)이라 허용.
10
11
  import { readdir, readFile } from 'node:fs/promises';
11
12
  import { join, relative } from 'node:path';
12
13
  import ts from 'typescript';
14
+ /** never 반환/throw 가드 헬퍼 — 응답 형태로 세지 않는다(결정 57). */
15
+ const GUARD_METHODS = new Set(['notFound', 'requireAuth']);
13
16
  /** 소스 문자열 하나에서 액션별 응답 종류를 수집한다(단위 테스트 진입점). */
14
17
  export function inspectControllerSource(file, source) {
15
18
  const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
@@ -79,6 +82,8 @@ function classifyReturns(body) {
79
82
  return 'redirect';
80
83
  if (c.name.text === 'json')
81
84
  return 'json';
85
+ if (GUARD_METHODS.has(c.name.text))
86
+ return 'guard';
82
87
  }
83
88
  return 'plain';
84
89
  }
@@ -120,7 +125,17 @@ export async function checkResponseMixing(cwd) {
120
125
  const source = await readFile(full, 'utf8');
121
126
  const usages = inspectControllerSource(full, source);
122
127
  for (const u of usages) {
123
- if (u.kinds.length < 2)
128
+ // 'guard'(notFound·requireAuth)는 응답 형태가 아니라 조기 종료라 카운트
129
+ // 제외(결정 57). 실제 응답 형태가 2종 이상일 때만 혼용 위반.
130
+ const responseKinds = u.kinds.filter((k) => k !== 'guard');
131
+ if (responseKinds.length < 2)
132
+ continue;
133
+ // 결정 57 보완(2026-07-26): {render, redirect} 조합은 혼용이 아니라 **폼
134
+ // 액션 정본**이다 — 실패 시 이전 페이지 render + 성공 시 redirect (공식
135
+ // gaon g auth 스캐폴드 session#create 동형 · Inertia SPA §6). 이 조합을
136
+ // 위반으로 잡으면 공식 스캐폴드가 doctor 를 통과하지 못하는 오탐이 된다.
137
+ const kindSet = new Set(responseKinds);
138
+ if (kindSet.size === 2 && kindSet.has('render') && kindSet.has('redirect'))
124
139
  continue;
125
140
  const rel = relative(cwd, full);
126
141
  issues.push({
@@ -128,10 +143,11 @@ export async function checkResponseMixing(cwd) {
128
143
  level: 'error',
129
144
  file: rel,
130
145
  line: u.line,
131
- message: `응답 혼용 감지: ${rel}:${u.line} · 액션 '${u.action}' 이 서로 다른 응답 형태를 섞습니다 (${u.kinds.join(', ')}).\n` +
146
+ message: `응답 혼용 감지: ${rel}:${u.line} · 액션 '${u.action}' 이 서로 다른 응답 형태를 섞습니다 (${responseKinds.join(', ')}).\n` +
132
147
  `→ 한 액션은 한 응답 형태만 유지하세요 (render | JSON | redirect · errata E-3 §C).\n` +
148
+ `→ 예외: 폼 액션의 "실패 render + 성공 redirect" 조합만 정본으로 허용됩니다 (결정 57 보완).\n` +
133
149
  `→ 조건 분기가 필요하면 액션을 둘로 나누거나, JSON 반환에는 예외를 던져 상태 코드를 표현하세요.`,
134
- detail: { action: u.action, kinds: [...u.kinds] },
150
+ detail: { action: u.action, kinds: [...responseKinds] },
135
151
  });
136
152
  }
137
153
  }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -13,6 +13,7 @@ export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-in
13
13
  export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './doctor/column-casing.js';
14
14
  export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
15
15
  export { checkPageFilename } from './doctor/page-filename.js';
16
+ export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
16
17
  export { renderHuman, renderJson } from './doctor/reporter.js';
17
18
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
18
19
  export interface DoctorCommandOptions {
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
3
3
  *
4
- * 12 검사를 조립한다:
4
+ * 13 검사를 조립한다:
5
5
  * 1) response-mixing (errata E-3 §C · 라이브)
6
6
  * 2) n-plus-one (errata E-4 (e))
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
@@ -14,6 +14,7 @@
14
14
  * 10) column-casing (§네이밍 · 결정 43·46 · 컬럼 camelCase)
15
15
  * 11) model-filename (§3.4 · 결정 32·46 · 모델 파일명 PascalCase)
16
16
  * 12) page-filename (§3.4 · 결정 32·46 · Vue 페이지 파일명 PascalCase)
17
+ * 13) auth-wiring (§7 · 결정 59 · requireAuth ↔ app.config.ts auth 배선)
17
18
  *
18
19
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
19
20
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -38,6 +39,7 @@ import { checkAgentsDocIndex } from './doctor/agents-doc-index.js';
38
39
  import { checkColumnCasing } from './doctor/column-casing.js';
39
40
  import { checkModelFilename } from './doctor/model-filename.js';
40
41
  import { checkPageFilename } from './doctor/page-filename.js';
42
+ import { checkAuthWiring } from './doctor/auth-wiring.js';
41
43
  import { renderHuman, renderJson } from './doctor/reporter.js';
42
44
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
43
45
  import { makeResult, } from './doctor/types.js';
@@ -54,10 +56,11 @@ export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-in
54
56
  export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './doctor/column-casing.js';
55
57
  export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
56
58
  export { checkPageFilename } from './doctor/page-filename.js';
59
+ export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
57
60
  export { renderHuman, renderJson } from './doctor/reporter.js';
58
61
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
59
62
  /**
60
- * 실행할 검사 이름. 지정 없음(undefined) = 12개 모두.
63
+ * 실행할 검사 이름. 지정 없음(undefined) = 13개 모두.
61
64
  */
62
65
  const ALL_RULES = [
63
66
  'response-mixing',
@@ -72,6 +75,7 @@ const ALL_RULES = [
72
75
  'column-casing',
73
76
  'model-filename',
74
77
  'page-filename',
78
+ 'auth-wiring',
75
79
  ];
76
80
  const CHECKERS = {
77
81
  'response-mixing': checkResponseMixing,
@@ -86,6 +90,7 @@ const CHECKERS = {
86
90
  'column-casing': checkColumnCasing,
87
91
  'model-filename': checkModelFilename,
88
92
  'page-filename': checkPageFilename,
93
+ 'auth-wiring': checkAuthWiring,
89
94
  };
90
95
  /**
91
96
  * `gaon doctor` 진입점 (M9-E · 확장 · M9-E-Fix 하드닝).
@@ -11,6 +11,8 @@ export interface ScaffoldResult {
11
11
  readonly created: string[];
12
12
  readonly skipped: string[];
13
13
  readonly patched: string[];
14
+ /** 손 수리가 필요한 지점 — 수리 안내 문장(§7.5.3)을 그대로 담는다. */
15
+ readonly warnings: string[];
14
16
  }
15
17
  /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다. */
16
18
  export declare function authScaffoldFiles(opts?: AuthScaffoldOptions): ScaffoldFile[];
package/dist/generate.js CHANGED
@@ -35,8 +35,11 @@ const TEMPLATES = [
35
35
  { tpl: 'Login.vue.tpl', out: (a) => `apps/${a}/pages/auth/Login.vue` },
36
36
  { tpl: 'Signup.vue.tpl', out: (a) => `apps/${a}/pages/auth/Signup.vue` },
37
37
  { tpl: 'Dashboard.vue.tpl', out: (a) => `apps/${a}/pages/Dashboard.vue` },
38
- { tpl: 'app.ts.tpl', out: (a) => `apps/${a}/app.ts` },
39
- { tpl: 'server.ts.tpl', out: () => 'server.ts' },
38
+ // 결정 59: 세션·인증 배선은 app.config.ts 표준 부팅(gaon dev/serve = wireGaon)이
39
+ // 소비한다. 과거의 수동 부팅 스캐폴드(app.ts·server.ts)는 번째 부팅 경로를
40
+ // 만들어 One Way 를 깨고, 표준 경로에는 auth 배선이 빠져 currentUser 가 영구
41
+ // null 이 되는 파손을 남겼다 — 제거했다.
42
+ { tpl: 'app.config.ts.tpl', out: (a) => `apps/${a}/app.config.ts` },
40
43
  ];
41
44
  /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다. */
42
45
  export function authScaffoldFiles(opts = {}) {
@@ -54,7 +57,11 @@ export function patchRoutes(existing) {
54
57
  if (!m || m.index === undefined)
55
58
  return null;
56
59
  const insertAt = m.index + m[0].length;
57
- const inject = "\n r.resource('session') // 로그인/로그아웃 (gaon g auth)" +
60
+ // dashboard 라우트도 함께 배선한다 없으면 스캐폴드 Dashboard.vue
61
+ // pageProps<'…:dashboard#show'> 가 라우트 맵에서 해상되지 않아 vue-tsc 가
62
+ // 깨진다(결정 60 클린룸 실측 — gaon new 프로젝트에 g auth 를 얹는 경로).
63
+ const inject = "\n r.get('/dashboard', 'dashboard#show') // 보호 페이지 (gaon g auth)" +
64
+ "\n r.resource('session') // 로그인/로그아웃 (gaon g auth)" +
58
65
  "\n r.resource('registration') // 회원가입 (gaon g auth)";
59
66
  return existing.slice(0, insertAt) + inject + existing.slice(insertAt);
60
67
  }
@@ -65,10 +72,23 @@ export function writeAuthScaffold(cwd, opts = {}) {
65
72
  const created = [];
66
73
  const skipped = [];
67
74
  const patched = [];
75
+ const warnings = [];
68
76
  for (const file of authScaffoldFiles(opts)) {
69
77
  const abs = join(root, file.path);
70
78
  if (existsSync(abs)) {
71
79
  skipped.push(file.path);
80
+ // 결정 59: app.config.ts 가 이미 있는데 auth 배선이 없으면 로그인 후에도
81
+ // currentUser 가 영구 null 이다 — skip 으로 끝내지 않고 수리 안내를 남긴다.
82
+ if (file.path === `apps/${app}/app.config.ts`) {
83
+ const existing = readFileSync(abs, 'utf8');
84
+ if (!/\bauth\s*:/.test(existing)) {
85
+ warnings.push(`apps/${app}/app.config.ts 가 이미 있어 인증 배선을 건너뛰었습니다.\n` +
86
+ `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음 두 항목을 추가하세요:\n` +
87
+ ` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' },\n` +
88
+ ` auth: { loadUser, loginRedirect: '/session/new' } // import { loadUser } from './auth.js'\n` +
89
+ `→ 이 배선이 없으면 로그인해도 this.currentUser/requireAuth() 가 사용자를 받지 못합니다.`);
90
+ }
91
+ }
72
92
  continue;
73
93
  }
74
94
  mkdirSync(dirname(abs), { recursive: true });
@@ -93,7 +113,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
93
113
  writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app), 'utf8');
94
114
  created.push(routesRel);
95
115
  }
96
- return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort() };
116
+ return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings };
97
117
  }
98
118
  /** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
99
119
  export function runGenerateAuthCommand(opts = {}) {
@@ -113,12 +133,14 @@ export function runGenerateAuthCommand(opts = {}) {
113
133
  lines.push(` ~ ${f} (라우트 추가)`);
114
134
  for (const f of result.skipped)
115
135
  lines.push(` · ${f} (이미 있음 — 건너뜀)`);
136
+ for (const w of result.warnings)
137
+ lines.push('', ` ⚠ ${w.split('\n').join('\n ')}`);
116
138
  lines.push('');
117
139
  lines.push(' 다음 단계:');
118
- lines.push(' 1) gaon dev .gaon 타입 브리지를 생성한다 (tables·routes).');
140
+ lines.push(' 1) .env REDIS_URL·SESSION_SECRET(32자 이상)·COOKIE_SECRET 설정한다.');
119
141
  lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
120
- lines.push(' 3) REDIS_URL·SESSION_SECRET·COOKIE_SECRET 설정하고 server.ts 실행한다.');
121
- lines.push(' → /registration/new 회원가입 · /session/new 로그인 · 보호 라우트는 this.requireAuth().');
142
+ lines.push(' 3) gaon dev 실행한다 (.gaon 타입 브리지 생성 + 세션·인증 자동 배선).');
143
+ lines.push(' → /registration/new 회원가입 · /session/new 로그인 · /dashboard 보호 페이지 (this.requireAuth()).');
122
144
  lines.push('');
123
145
  process.stdout.write(lines.join('\n') + '\n');
124
146
  return 0;
package/dist/index.js CHANGED
@@ -98,7 +98,7 @@ function renderHelp(version = VERSION) {
98
98
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
99
99
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
100
100
  " gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
101
- " gaon doctor 정적 검사 (12 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례)",
101
+ " gaon doctor 정적 검사 (13 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선)",
102
102
  " gaon doctor --json 자동화용 JSON 출력",
103
103
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
104
104
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -110,7 +110,7 @@ function renderHelp(version = VERSION) {
110
110
  " gaon g job <Name> 비동기 잡 (domain/jobs · later/in/at)",
111
111
  " gaon g app <name> 앱 스캐폴드 (apps/<name>/ · routes·controllers·pages·layouts)",
112
112
  " gaon g <type> --overwrite 기존 파일 덮어쓰기 · --app <이름> · --json",
113
- " gaon mcp 내장 MCP 서버 · AI 도구 4종 (list_routes·get_schema·run_migration·run_tests · --http)",
113
+ " gaon mcp 내장 MCP 서버 · AI 도구 7종 (list_routes·get_schema·run_migration·run_tests·read_agent_doc·run_check·run_doctor · --http)",
114
114
  " gaon hub 실시간 허브 프로세스 (프레즌스 권위·중계 · 리더 선출 HA)",
115
115
  " gaon work 워커 프로세스 (잡·리스너·스케줄·아웃박스 · graceful drain)",
116
116
  " gaon jobs list --failed DLQ(실패 잡) 목록",
@@ -223,7 +223,7 @@ export function runCli(argv, opts = {}) {
223
223
  });
224
224
  return;
225
225
  }
226
- // `gaon doctor` — 정적 검사(M9-E · 12 검사). --check=<이름>[,<이름>...] 로
226
+ // `gaon doctor` — 정적 검사(M9-E · 13 검사). --check=<이름>[,<이름>...] 로
227
227
  // 선택 실행, --json 은 자동화 파싱용.
228
228
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
229
229
  if (argv[0] === "doctor") {
@@ -249,9 +249,10 @@ export function runCli(argv, opts = {}) {
249
249
  });
250
250
  return;
251
251
  }
252
- // `gaon mcp` — 내장 MCP 서버(M10-B · 정본 §12 질문 8). AI 에이전트에게 4
253
- // 도구(list_routes·get_schema·run_migration·run_tests)를 노출한다. stdio(기본)
254
- // 또는 --http 전송. 표시 버전은 파사드 버전을 주입한다.
252
+ // `gaon mcp` — 내장 MCP 서버(M10-B · 정본 §12 질문 8). AI 에이전트에게 7
253
+ // 도구를 노출한다: 원문 4종(list_routes·get_schema·run_migration·run_tests)
254
+ // + read_agent_doc(결정 40) + run_check·run_doctor(결정 63 검증 루프).
255
+ // stdio(기본) 또는 --http 전송. 표시 버전은 파사드 버전을 주입한다.
255
256
  if (argv[0] === "mcp") {
256
257
  void runMcpCommand({
257
258
  json: argv.includes("--json"),
@@ -82,6 +82,27 @@ export declare function runTestsTool(args: ToolArgs, cwd: string): Promise<ToolR
82
82
  * 생략 시 사용 가능한 카테고리 목록을 반환.
83
83
  */
84
84
  export declare function readAgentDocTool(args: ToolArgs, cwd: string): Promise<ToolResult>;
85
+ /**
86
+ * `run_check` — `gaon check` 를 실 실행한다(규칙 3 · 검사 전 `.gaon` 재생성 포함).
87
+ *
88
+ * 컴파일 정답률(v3)의 정의 그 자체이자 CLAUDE.md §3 검증 루프의 본체다.
89
+ * AI 에이전트가 자기 산출물을 스스로 확증하는 표준 경로.
90
+ *
91
+ * 인자:
92
+ * { only?: 'typecheck'|'vue-tsc'|'build'|'doctor', includeDoctor?: boolean }
93
+ */
94
+ export declare function runCheckTool(args: ToolArgs, cwd: string): Promise<ToolResult>;
95
+ /**
96
+ * `run_doctor` — `gaon doctor` 를 실 실행한다(관례 강제 검사).
97
+ *
98
+ * 기계적으로 검사 가능한 관례는 doctor 가 정본이다(§7.5.3 — 에러가 곧 수리
99
+ * 안내서). `fix` 는 기본이 dry-run(계획만)이고, 실제 파일 편집은 `yes` 를
100
+ * 함께 줘야 일어난다 — CLI 규약(`--fix` + `--yes`)을 그대로 따른다.
101
+ *
102
+ * 인자:
103
+ * { checks?: string, fix?: boolean, yes?: boolean }
104
+ */
105
+ export declare function runDoctorTool(args: ToolArgs, cwd: string): Promise<ToolResult>;
85
106
  /** MCP 도구 스펙 — MCP 클라이언트에게 노출할 이름·설명·입력 스키마. */
86
107
  export interface McpToolSpec {
87
108
  readonly name: string;
@@ -90,8 +111,9 @@ export interface McpToolSpec {
90
111
  readonly run: (args: ToolArgs, cwd: string) => Promise<ToolResult>;
91
112
  }
92
113
  /**
93
- * 정본 §12 질문 8 확정 명세 4종을 카탈로그로 제공한다. 순서 = 원문 순서
94
- * (라우트 · 스키마 · 마이그레이션 · 테스트).
114
+ * 도구 카탈로그. 앞 4종은 정본 §12 질문 8 확정 명세이며 순서도 원문 그대로
115
+ * (라우트 · 스키마 · 마이그레이션 · 테스트), 뒤이어 `read_agent_doc`(결정 40
116
+ * 2층 문서 조회) · `run_check`·`run_doctor`(결정 63 검증 루프 노출)가 붙는다.
95
117
  */
96
118
  export declare const TOOLS: readonly McpToolSpec[];
97
119
  /**
package/dist/mcp/tools.js CHANGED
@@ -382,9 +382,136 @@ export async function readAgentDocTool(args, cwd) {
382
382
  isError: false,
383
383
  };
384
384
  }
385
+ // ── 도구 6·7: run_check · run_doctor ────────────────────────
385
386
  /**
386
- * 정본 §12 질문 8 확정 명세 4종을 카탈로그로 제공한다. 순서 = 원문 순서
387
- * (라우트 · 스키마 · 마이그레이션 · 테스트).
387
+ * `gaon` 실행 파일을 자식 프로세스로 돌려 stdout/stderr 캡처한다.
388
+ *
389
+ * 결정 63: `runCheckCommand`·`runDoctorCommand` 를 in-process 로 부르면 안 된다 —
390
+ * 둘 다 `process.stdout.write` 로 결과를 찍는데, MCP 서버의 전송이
391
+ * StdioServerTransport(stdout = JSON-RPC 채널)라 프로토콜 스트림이 깨진다.
392
+ * `run_tests` 와 같은 spawn 경로를 쓰면 자식 stdout 이 파이프로 격리되고,
393
+ * 덤으로 사용자가 실제로 밟는 CLI 경로 그대로를 검증하게 된다.
394
+ */
395
+ async function spawnGaon(cwd, cliArgs) {
396
+ const bin = join(resolvePath(cwd), 'node_modules', '.bin', 'gaon');
397
+ if (!existsSync(bin)) {
398
+ return {
399
+ error: `gaon 실행 파일이 없습니다: ${bin}\n` +
400
+ `→ 프로젝트에서 \`npm i gaonjs\` (또는 pnpm add gaonjs) 로 설치한 뒤 다시 시도하세요.`,
401
+ };
402
+ }
403
+ const child = spawn(bin, [...cliArgs], { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
404
+ let stdout = '';
405
+ let stderr = '';
406
+ child.stdout.on('data', (d) => {
407
+ stdout += d.toString('utf8');
408
+ });
409
+ child.stderr.on('data', (d) => {
410
+ stderr += d.toString('utf8');
411
+ });
412
+ const exitCode = await new Promise((resolveExit) => {
413
+ child.on('close', (code) => resolveExit(code ?? 1));
414
+ });
415
+ return { exitCode, output: stdout + (stderr ? `\n[stderr]\n${stderr}` : '') };
416
+ }
417
+ /** stdout 에서 `--json` 산출을 건져낸다. 사람용 줄이 섞여 있어도 마지막 JSON 객체를 취한다. */
418
+ function parseJsonTail(output) {
419
+ const lines = output.split('\n');
420
+ for (let i = lines.length - 1; i >= 0; i--) {
421
+ const s = (lines[i] ?? '').trim();
422
+ if (!s.startsWith('{'))
423
+ continue;
424
+ try {
425
+ return JSON.parse(s);
426
+ }
427
+ catch {
428
+ // 사람용 출력에 섞인 중괄호 줄 — 계속 위로 훑는다.
429
+ }
430
+ }
431
+ return undefined;
432
+ }
433
+ /**
434
+ * `run_check` — `gaon check` 를 실 실행한다(규칙 3 · 검사 전 `.gaon` 재생성 포함).
435
+ *
436
+ * 컴파일 정답률(v3)의 정의 그 자체이자 CLAUDE.md §3 검증 루프의 본체다.
437
+ * AI 에이전트가 자기 산출물을 스스로 확증하는 표준 경로.
438
+ *
439
+ * 인자:
440
+ * { only?: 'typecheck'|'vue-tsc'|'build'|'doctor', includeDoctor?: boolean }
441
+ */
442
+ export async function runCheckTool(args, cwd) {
443
+ const only = stringOpt(args, 'only');
444
+ const includeDoctor = boolOpt(args, 'includeDoctor');
445
+ const known = ['typecheck', 'vue-tsc', 'build', 'doctor'];
446
+ if (only && !known.includes(only)) {
447
+ return errorResult(`only 는 ${known.map((k) => `'${k}'`).join(' | ')} 중 하나여야 합니다. 입력값: '${only}'\n` +
448
+ `→ 인자를 고치거나 생략해 전체 검사를 실행하세요.`);
449
+ }
450
+ const cliArgs = ['check', '--json', ...(only ? ['--only', only] : []), ...(includeDoctor ? ['--include-doctor'] : [])];
451
+ const r = await spawnGaon(cwd, cliArgs);
452
+ if ('error' in r)
453
+ return errorResult(r.error);
454
+ return {
455
+ text: r.output || `(gaon check 출력 없음 · exit ${r.exitCode})`,
456
+ data: {
457
+ ok: r.exitCode === 0,
458
+ exitCode: r.exitCode,
459
+ only: only ?? null,
460
+ includeDoctor,
461
+ report: parseJsonTail(r.output) ?? null,
462
+ output: r.output,
463
+ },
464
+ isError: r.exitCode !== 0,
465
+ };
466
+ }
467
+ /**
468
+ * `run_doctor` — `gaon doctor` 를 실 실행한다(관례 강제 검사).
469
+ *
470
+ * 기계적으로 검사 가능한 관례는 doctor 가 정본이다(§7.5.3 — 에러가 곧 수리
471
+ * 안내서). `fix` 는 기본이 dry-run(계획만)이고, 실제 파일 편집은 `yes` 를
472
+ * 함께 줘야 일어난다 — CLI 규약(`--fix` + `--yes`)을 그대로 따른다.
473
+ *
474
+ * 인자:
475
+ * { checks?: string, fix?: boolean, yes?: boolean }
476
+ */
477
+ export async function runDoctorTool(args, cwd) {
478
+ const checks = stringOpt(args, 'checks');
479
+ const fix = boolOpt(args, 'fix');
480
+ const yes = boolOpt(args, 'yes');
481
+ if (yes && !fix) {
482
+ return errorResult('yes=true 는 fix=true 와 함께여야 합니다 — 단독으로는 아무것도 하지 않습니다.\n' +
483
+ '→ 자동 정정을 적용하려면 { fix: true, yes: true }, 계획만 보려면 { fix: true } 로 호출하세요.');
484
+ }
485
+ const cliArgs = [
486
+ 'doctor',
487
+ '--json',
488
+ ...(checks ? [`--check=${checks}`] : []),
489
+ ...(fix ? ['--fix'] : []),
490
+ ...(fix && yes ? ['--yes'] : []),
491
+ ];
492
+ const r = await spawnGaon(cwd, cliArgs);
493
+ if ('error' in r)
494
+ return errorResult(r.error);
495
+ // exit 2 = fatal(사용자 오류 · 크래시 아님) · 1 = 위반 있음 · 0 = 통과.
496
+ return {
497
+ text: r.output || `(gaon doctor 출력 없음 · exit ${r.exitCode})`,
498
+ data: {
499
+ ok: r.exitCode === 0,
500
+ exitCode: r.exitCode,
501
+ fatal: r.exitCode === 2,
502
+ checks: checks ?? null,
503
+ fix,
504
+ applied: fix && yes,
505
+ report: parseJsonTail(r.output) ?? null,
506
+ output: r.output,
507
+ },
508
+ isError: r.exitCode !== 0,
509
+ };
510
+ }
511
+ /**
512
+ * 도구 카탈로그. 앞 4종은 정본 §12 질문 8 확정 명세이며 순서도 원문 그대로
513
+ * (라우트 · 스키마 · 마이그레이션 · 테스트), 뒤이어 `read_agent_doc`(결정 40
514
+ * 2층 문서 조회) · `run_check`·`run_doctor`(결정 63 검증 루프 노출)가 붙는다.
388
515
  */
389
516
  export const TOOLS = [
390
517
  {
@@ -462,6 +589,43 @@ export const TOOLS = [
462
589
  },
463
590
  run: readAgentDocTool,
464
591
  },
592
+ {
593
+ name: 'run_check',
594
+ description: '`gaon check` 를 실 실행한다 — 검사 전에 .gaon 타입 브리지를 재생성(규칙 3)한 뒤 ' +
595
+ 'typecheck·vue-tsc·build 를 순서대로 돌린다. 산출물을 고친 뒤 스스로 확증하는 표준 경로 ' +
596
+ '(CLAUDE.md §3 검증 루프). only 로 한 단계만, includeDoctor 로 doctor 까지 포함.',
597
+ inputSchema: {
598
+ type: 'object',
599
+ properties: {
600
+ only: {
601
+ type: 'string',
602
+ enum: ['typecheck', 'vue-tsc', 'build', 'doctor'],
603
+ description: '지정 시 그 검사 하나만 실행. 생략 시 전체.',
604
+ },
605
+ includeDoctor: { type: 'boolean', description: 'true 면 doctor 도 함께 실행. 기본 false.' },
606
+ },
607
+ additionalProperties: false,
608
+ },
609
+ run: runCheckTool,
610
+ },
611
+ {
612
+ name: 'run_doctor',
613
+ description: '`gaon doctor` 를 실 실행한다 — 기계적으로 검사 가능한 관례 위반을 찾고 수리 안내까지 낸다(§7.5.3). ' +
614
+ 'checks 로 일부 규칙만 선택, fix=true 는 dry-run(계획만), 실제 파일 편집은 fix+yes 를 함께 줘야 한다.',
615
+ inputSchema: {
616
+ type: 'object',
617
+ properties: {
618
+ checks: {
619
+ type: 'string',
620
+ description: "쉼표로 구분한 규칙 이름(예: 'schema-filename,model-filename'). 생략 시 전 규칙.",
621
+ },
622
+ fix: { type: 'boolean', description: 'true 면 자동 정정 계획(dry-run). 편집하려면 yes 도 필요.' },
623
+ yes: { type: 'boolean', description: 'fix 와 함께 줘야 실제 파일이 편집된다(원본은 .bak 백업).' },
624
+ },
625
+ additionalProperties: false,
626
+ },
627
+ run: runDoctorTool,
628
+ },
465
629
  ];
466
630
  /**
467
631
  * 이름으로 도구를 찾는다. 없으면 undefined.
@@ -112,8 +112,8 @@ export function appScaffoldFiles(name) {
112
112
  `<script setup lang="ts">`,
113
113
  `import { pageProps } from 'gaonjs/vue'`,
114
114
  ``,
115
- `// home#index 의 render props — Serialized<> 로 넘어온다(§6.2).`,
116
- `const props = pageProps<'home#index'>()`,
115
+ `// ${name}:home#index 의 render props — Serialized<> 로 넘어온다(§6.2).`,
116
+ `const props = pageProps<'${name}:home#index'>()`,
117
117
  `</script>`,
118
118
  ``,
119
119
  `<template>`,
@@ -22,8 +22,10 @@ export function pageScaffold(pagePath, app) {
22
22
  const segments = trimmed.split('/').filter(Boolean);
23
23
  const last = segments[segments.length - 1];
24
24
  const parent = segments[segments.length - 2] ?? last;
25
- // 라우트 키 기본값 — 폴더는 리소스명(복수·소문자), 파일은 액션명(소문자).
26
- const routeKey = `${toCamel(parent).toLowerCase()}#${toCamel(last).toLowerCase()}`;
25
+ // 라우트 키 기본값 — 접두(결정 55) + 폴더=리소스명(복수·소문자) +
26
+ // 파일=액션명(소문자). 앱 네임스페이스가 있어야 멀티앱에서 전역 GaonRouteMap
27
+ // 충돌이 없다.
28
+ const routeKey = `${app}:${toCamel(parent).toLowerCase()}#${toCamel(last).toLowerCase()}`;
27
29
  const filePath = `apps/${app}/pages/${trimmed}.vue`;
28
30
  const lines = [
29
31
  `<script setup lang="ts">`,
@@ -2,7 +2,7 @@
2
2
  import { pageProps } from 'gaonjs/vue'
3
3
 
4
4
  // dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
5
- const { user } = pageProps<'dashboard#show'>()
5
+ const { user } = pageProps<'{{APP_NAME}}:dashboard#show'>()
6
6
  </script>
7
7
 
8
8
  <template>
@@ -2,7 +2,7 @@
2
2
  import { pageProps } from 'gaonjs/vue'
3
3
 
4
4
  // 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2).
5
- const { error, csrf } = pageProps<'session#new'>()
5
+ const { error, csrf } = pageProps<'{{APP_NAME}}:session#new'>()
6
6
  </script>
7
7
 
8
8
  <template>
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps } from 'gaonjs/vue'
3
3
 
4
- const { error, csrf } = pageProps<'registration#new'>()
4
+ const { error, csrf } = pageProps<'{{APP_NAME}}:registration#new'>()
5
5
  </script>
6
6
 
7
7
  <template>
@@ -0,0 +1,11 @@
1
+ // 앱 설정 — gaon g auth 스캐폴드. 세션·인증을 표준 부팅(gaon dev / gaon serve)에 배선한다.
2
+ import { defineAppConfig } from 'gaonjs/config'
3
+ import { loadUser } from './auth.js'
4
+
5
+ export default defineAppConfig({
6
+ // 세션 secret 은 32자 이상 — .env 의 SESSION_SECRET 로 주입한다.
7
+ session: { secret: process.env.SESSION_SECRET ?? 'dev-only-session-secret-change-me-now!!' },
8
+ // 결정 59: 세션 userId → 사용자 로드 배선. 이게 있어야 로그인 후
9
+ // this.currentUser / this.requireAuth() 가 실제 사용자를 받는다.
10
+ auth: { loadUser, loginRedirect: '/session/new' },
11
+ })
@@ -2,7 +2,7 @@
2
2
  import { controller } from 'gaonjs/web'
3
3
 
4
4
  export default controller({
5
- // GET / — 로그인해야 볼 수 있는 홈. 비로그인은 로그인 페이지로 보내진다.
5
+ // GET /dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
6
6
  async show() {
7
7
  const user = this.requireAuth()
8
8
  return this.render('Dashboard', { user })
@@ -15,6 +15,6 @@ export default controller({
15
15
  const passwordDigest = await hashPassword(password)
16
16
  const user = await User.create({ name, email, passwordDigest })
17
17
  this.auth.login(user)
18
- return this.redirect('/')
18
+ return this.redirect('/dashboard')
19
19
  },
20
20
  })
@@ -1,7 +1,7 @@
1
1
  import { routes } from 'gaonjs/web'
2
2
 
3
3
  export default routes((r) => {
4
- r.get('/', 'dashboard#show') // 보호 (gaon g auth)
4
+ r.get('/dashboard', 'dashboard#show') // 보호 페이지 (gaon g auth)
5
5
  r.resource('session') // 로그인/로그아웃 (gaon g auth)
6
6
  r.resource('registration') // 회원가입 (gaon g auth)
7
7
  })
@@ -14,7 +14,7 @@ export default controller({
14
14
  // passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
15
15
  if (user && (await verifyPassword(password, user.passwordDigest))) {
16
16
  this.auth.login(user)
17
- return this.redirect('/')
17
+ return this.redirect('/dashboard')
18
18
  }
19
19
  return this.render('auth/Login', {
20
20
  error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
@@ -103,7 +103,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
103
103
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
104
104
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
105
105
 
106
- ### 2.2 `gaon doctor` 검사 12
106
+ ### 2.2 `gaon doctor` 검사 13
107
107
 
108
108
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
109
109
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -117,6 +117,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
117
117
  10. `column-casing` — 스키마 컬럼명 camelCase 관례 (결정 43·46 · 스네이크 컬럼 검출)
118
118
  11. `model-filename` — 모델 파일명 PascalCase 관례 (결정 32·46 · `--fix` 지원)
119
119
  12. `page-filename` — Vue 페이지 파일명 PascalCase 관례 (결정 32·46)
120
+ 13. `auth-wiring` — requireAuth/this.auth 사용 ↔ `app.config.ts` 인증 배선 (결정 59)
120
121
 
121
122
  ## 3. 로직 배치 One Way 판단표
122
123
 
@@ -157,7 +158,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
157
158
  ```bash
158
159
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
159
160
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
160
- gaon doctor # 정적 검사 12종 (§2.2)
161
+ gaon doctor # 정적 검사 13종 (§2.2)
161
162
  ```
162
163
 
163
164
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -172,7 +173,7 @@ gaon doctor # 정적 검사 12종 (§2.2)
172
173
  | `gaon check` / `test` / `doctor` | 검증 루프 |
173
174
  | `gaon console` | 프로젝트 컨텍스트 REPL |
174
175
  | `gaon jobs` | DLQ 조회·재적재 |
175
- | `gaon mcp` | 내장 MCP 서버 — 도구: `list_routes`·`get_schema`·`run_migration`·`run_tests`·`read_agent_doc` |
176
+ | `gaon mcp` | 내장 MCP 서버 — 도구 7종: `list_routes`·`get_schema`·`run_migration`·`run_tests`·`read_agent_doc`·`run_check`·`run_doctor` |
176
177
 
177
178
  `gaon mcp` 는 프레임웍이 자신을 AI 도구로 노출한다 (§7.5.3) — grep
178
179
  으로 더듬는 대신 프레임웍에게 직접 묻는다. `read_agent_doc` 은 §0
@@ -285,7 +285,7 @@ const rows = await Post.query()
285
285
 
286
286
  - `packages/vue/src/serialize.ts:28` — **`Serialized<T>`**: 임의 값의
287
287
  JSON-safe 매핑 (Date → string, bigint → string, Hidden 브랜드 제외).
288
- `api()` 반환 타입 · `pageProps<'ctrl#action'>()` 결과 타입이 이것.
288
+ `api()` 반환 타입 · `pageProps<'app:ctrl#action'>()` 결과 타입이 이것.
289
289
  - `packages/data/src/schema.ts:544` — **`SerializedOf<Defs>`**: 테이블
290
290
  스키마 defs 의 SerializedOf — hidden 컬럼 키 제외한 Row.
291
291
 
@@ -342,6 +342,10 @@ const post = await Post.create({ title: '제목', body: '...', authorId: user.id
342
342
  await post.publish() // 인스턴스 메서드
343
343
  ```
344
344
 
345
+ - **두 번째 인자는 생략할 수 없다** (결정 62). 스코프·메서드·훅이 하나도
346
+ 없어도 빈 객체를 넘긴다 — 최소형은 `model(posts, {})` 다. `model(posts)`
347
+ 는 타입 에러이며, `gaon check` 는 검사 전에 `.gaon` 을 재생성하느라
348
+ 타입 진단보다 먼저 런타임 에러로 멈춘다.
345
349
  - **두 번째 인자** = `{ scopes?, methods?, hooks? }`. `scopes` 값은
346
350
  `(q) => q.where(...)` 형태로 쿼리를 좁히는 함수, `methods` 는 인스턴스
347
351
  메서드(`this` 로 레코드 필드·관계에 접근), `hooks` 는 `beforeCreate`
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 정본 규칙
8
8
 
9
- ### 1. 페이지(Vue) — `pageProps<'ctrl#action'>()` 로 컨트롤러 props 수신
9
+ ### 1. 페이지(Vue) — `pageProps<'app:ctrl#action'>()` 로 컨트롤러 props 수신
10
10
 
11
11
  컨트롤러의 `this.render('Posts/Index', {...})` 가 넘긴 render props 를 페이지가 받는
12
12
  방식은 **하나뿐** — `gaonjs/vue` 의 `pageProps<K>()` 헬퍼. 라우트 키 `K` 로부터
@@ -19,8 +19,8 @@
19
19
  <script setup lang="ts">
20
20
  import { pageProps } from 'gaonjs/vue' // 파사드 · 서브패스 X
21
21
 
22
- // 라우트 posts#index 의 컨트롤러 render props 가 Serialized 로 흘러들어온다.
23
- const props = pageProps<'posts#index'>()
22
+ // 라우트 web:posts#index 의 컨트롤러 render props 가 Serialized 로 흘러들어온다.
23
+ const props = pageProps<'web:posts#index'>()
24
24
  </script>
25
25
 
26
26
  <template>
@@ -31,8 +31,9 @@ const props = pageProps<'posts#index'>()
31
31
  </template>
32
32
  ```
33
33
 
34
- - **라우트 키** = `<controller>#<action>` (컨트롤러 파일명 stem · 소문자 복수).
35
- `apps/web/controllers/posts.ts` 의 `index` 액션 → `'posts#index'`.
34
+ - **라우트 키** = `<app>:<controller>#<action>` ( 폴더명 · 컨트롤러 파일명
35
+ stem · 소문자 복수 · 결정 55). `apps/web/controllers/posts.ts` 의 `index`
36
+ 액션 → `'web:posts#index'`.
36
37
  - **파사드는 `gaonjs/vue`** — `@gaonjs/vue` (스코프)·`@inertiajs/vue3` (내부 의존)
37
38
  로 import 하지 않는다.
38
39
  - **`shared/` 밖에서만 사용** — `shared/` 안 `pageProps` 사용은 §4 대칭 표에서
@@ -52,7 +53,7 @@ import { api } from 'gaonjs/vue' // 파사드 · 서브패
52
53
 
53
54
  async function search(q: string) {
54
55
  // 첫 인자 = 'controller#action' 라우트 키, 두 번째 = 라우트 파라미터 + 쿼리/바디
55
- const { results } = await api('posts#search', { q })
56
+ const { results } = await api('web:posts#search', { q })
56
57
  return results
57
58
  }
58
59
  </script>
@@ -159,11 +160,11 @@ import { ref } from 'vue'
159
160
  import { pageProps, api } from 'gaonjs/vue'
160
161
  import PostCard from '../../components/PostCard.vue'
161
162
 
162
- const props = pageProps<'posts#index'>()
163
+ const props = pageProps<'web:posts#index'>()
163
164
  const results = ref<Awaited<ReturnType<typeof runSearch>>>([])
164
165
 
165
166
  async function runSearch(q: string) {
166
- const res = await api('posts#search', { q })
167
+ const res = await api('web:posts#search', { q })
167
168
  return res.results
168
169
  }
169
170
  </script>
@@ -35,7 +35,9 @@
35
35
  ```ts
36
36
  import { connectNats } from 'gaonjs/async'
37
37
 
38
- const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')
38
+ // 인자 없이 부르면 env NATS_URL(없으면 nats://127.0.0.1:4222)에 붙는다.
39
+ // 명시하려면 옵션 객체: connectNats({ servers: 'nats://...', name: '...' }).
40
+ const nats = await connectNats()
39
41
  // ... 검증 ...
40
42
  await nats.close()
41
43
  ```
@@ -58,7 +60,7 @@ import { SendWelcomeMail } from '../../domain/jobs/sendWelcomeMail.js'
58
60
 
59
61
  describe('SendWelcomeMail (실 NATS JetStream)', () => {
60
62
  it('발행한 잡이 워커에서 처리된다', async () => {
61
- const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')
63
+ const nats = await connectNats() // env NATS_URL 기본 nats://127.0.0.1:4222
62
64
  try {
63
65
  await expectJobProcessed(SendWelcomeMail, () => SendWelcomeMail.later(1n), { nats })
64
66
  } finally {
@@ -24,12 +24,16 @@ export default controller({
24
24
 
25
25
  // JSON 액션 — 객체를 반환하면 JSON 응답
26
26
  async search() {
27
- const { q } = this.params(Post.searchForm) // 검증 실패 시 자동 422 JSON
27
+ const { q } = this.params({ _row: {} as { q: string } }) // 검증 실패 시 자동 422 JSON
28
28
  return { results: await Post.published().search(q).limit(10).all() }
29
29
  },
30
30
  })
31
31
  ```
32
32
 
33
+ - **액션 이름 = 메서드 관례(camelCase · §2.1)** — JSON 액션도 컨트롤러
34
+ 메서드이므로 `tagAdd`(○) `tag_add`(✗). 라우트 참조·`api()` 키도 같은
35
+ 이름을 쓴다: `r.post('/posts/:id/tagAdd', 'posts#tagAdd')` ·
36
+ `api('web:posts#tagAdd', ...)`.
33
37
  - **import 관례** — 프레임웍 심볼은 파사드 `gaonjs/*` 에서 (`gaonjs/web`·
34
38
  `gaonjs/data`·`gaonjs/vue`·`gaonjs/async` 등), 도메인 모델은 상대경로
35
39
  `../../../domain/models/<Pascal>.js` 로 참조한다. `@gaonjs/*` (스코프
@@ -40,11 +44,14 @@ export default controller({
40
44
  - **리소스 부재 = `this.notFound()`** — 조회 결과가 없으면 404 를 손으로 만들지
41
45
  말고 `this.notFound()` 로 마감한다(`requireAuth()` 와 동형). `never` 를 반환해
42
46
  이 뒤로 값이 존재하는 것으로 좁혀지므로, `render` 반환 타입(타입 브리지)도 그대로
43
- 유지된다. 메시지는 선택: `this.notFound('post 없음')`.
47
+ 유지된다. 메시지는 선택: `this.notFound('post 없음')`. 부재 가능 조회는
48
+ `.where(...).first()`(없으면 undefined)로 받는다 — **`Post.find(id)` 는 없으면
49
+ throw** 라(부재를 허용하지 않는 조회) `if (!post)` 가드가 죽은 코드가 된다.
44
50
 
45
51
  ```ts
46
52
  async show() {
47
- const post = await Post.find(this.params(Post.showForm).id)
53
+ const { id } = this.params({ _row: {} as { id: string } })
54
+ const post = await Post.where('id', '=', BigInt(id)).first()
48
55
  if (!post) return this.notFound() // 404 · 이 뒤로 post 는 non-null
49
56
  return this.render('Posts/Show', { post })
50
57
  }
@@ -55,6 +62,11 @@ export default controller({
55
62
  - **한 액션은 한 종류 응답만 낸다** (render 또는 JSON 또는 redirect —
56
63
  조건 분기 혼용 금지). `gaon doctor` 의 **response-mixing** 검사가
57
64
  혼용을 잡는다.
65
+ - **단 하나의 예외 — 폼 액션 정본** (결정 57 보완): 폼을 받는 액션은
66
+ **실패 시 이전 페이지 render + 성공 시 redirect** 조합이 정본이다
67
+ (`gaon g auth` 의 session#create 동형 · Inertia SPA §6). 이
68
+ {render, redirect} 조합만은 doctor 가 허용한다 — render+JSON,
69
+ redirect+JSON 등 나머지 조합은 여전히 위반.
58
70
  - 검증은 페이지 액션과 동일하게 `this.params(스키마)` — 실패 시
59
71
  422 JSON.
60
72
  - 인증·세션은 같은 앱의 세션 쿠키를 그대로 쓴다. CSRF·rate limit 등
@@ -129,7 +141,56 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
129
141
  - 세션은 앱별 완전 분리 (Fastify 캡슐화 스코프): 쿠키 이름(`<app>_sid`) ·
130
142
  서명 secret · Redis 키 prefix · 쿠키 path 가 앱 단위로 갇힌다.
131
143
  - 스캐폴드는 `gaon g auth` — 로그인/회원가입 컨트롤러·페이지·라우트 일습.
132
- - 로그인 필요 액션은 `this.requireAuth()` 관례 (notFound 동형 — never 좁힘).
144
+ - **로그인 필요 액션의 정답 = `this.requireAuth()`** (결정 57). notFound 처럼
145
+ 예외로 마감하지만, **`if` 가드 자체가 없다**는 게 핵심:
146
+
147
+ ```ts
148
+ async new() {
149
+ this.requireAuth() // 미인증이면 로그인으로 리다이렉트
150
+ return this.render('Posts/New', { csrf: this.csrfToken() })
151
+ }
152
+ async create() {
153
+ const user = this.requireAuth() // 반환값을 바로 쓴다
154
+ const post = await Post.create({ ...this.params(form), authorId: user.id })
155
+ return this.redirect(`/posts/${String(post.id)}`)
156
+ }
157
+ ```
158
+
159
+ - **`this.auth.user`/`requireAuth()` 사용자 타입은 앱이 증강한다** (결정 58).
160
+ `GaonCurrentUser` 는 빈 인터페이스라 증강 없이는 `user.id` 접근이 타입에러다.
161
+ `gaon g auth` 가 `apps/<app>/auth.ts` 에 심는다:
162
+
163
+ ```ts
164
+ declare module 'gaonjs/web' {
165
+ interface GaonCurrentUser { id: bigint; name: string; email: string }
166
+ }
167
+ ```
168
+
169
+ - **인증 배선 = `apps/<app>/app.config.ts` — 로그인 기능의 필수 구성 요소다**
170
+ (결정 59). 컨트롤러·페이지만 만들면 컴파일은 통과하지만, 이 배선이 없으면
171
+ 세션에 로그인해도 요청마다 사용자를 로드할 길이 없어 `this.currentUser`/
172
+ `requireAuth()` 가 **영구 null** 이다(`gaon doctor` 의 `auth-wiring` 검사가
173
+ 잡는다). 로그인 기능을 추가할 때는 반드시 함께 배선한다:
174
+
175
+ ```ts
176
+ // apps/web/app.config.ts — 세션 + 인증 배선 (gaon serve 가 자동 소비)
177
+ import { defineAppConfig } from 'gaonjs/config'
178
+ import { loadUser } from './auth.js'
179
+
180
+ export default defineAppConfig({
181
+ session: { secret: process.env.SESSION_SECRET ?? 'dev-only-session-secret-change-me-now!!' },
182
+ auth: { loadUser, loginRedirect: '/session/new' },
183
+ })
184
+ ```
185
+
186
+ ```ts
187
+ // apps/web/auth.ts — 세션 userId → 사용자 로드 이음새 (gaon g auth 가 생성)
188
+ import type { AuthOptions } from 'gaonjs/web'
189
+ import { User } from '../../domain/models/User.js'
190
+
191
+ export const loadUser: AuthOptions['loadUser'] = async (id) =>
192
+ await User.where('id', '=', BigInt(String(id))).first()
193
+ ```
133
194
 
134
195
  ## 정본 예시
135
196
 
@@ -144,7 +205,10 @@ export default controller({
144
205
  return this.render('Auth/Register', {})
145
206
  },
146
207
  async create() {
147
- const user = await RegisterUser.call(this.params(RegisterUser.form))
208
+ const { name, email, password } = this.params({
209
+ _row: {} as { name: string; email: string; password: string },
210
+ })
211
+ const user = await RegisterUser.call({ name, email, password })
148
212
  await SendWelcomeMail.later(user.id) // 잡 발행 위치는 결정 32 — 서비스 afterCommit 도 정합
149
213
  return this.redirect('/dashboard')
150
214
  },
@@ -154,7 +218,8 @@ export default controller({
154
218
  ## 알려진 함정
155
219
 
156
220
  - **render/JSON/redirect 를 한 액션에서 조건 혼용하면 doctor
157
- response-mixing 위반** — 액션을 나눈다.
221
+ response-mixing 위반** — 액션을 나눈다. 예외는 폼 액션의
222
+ "실패 render + 성공 redirect" 조합 하나뿐(결정 57 보완).
158
223
  - **`fetch()` 로 로그인 폼 구현 금지** — 세션 앱 폼은 `Inertia.post()`.
159
224
  REST + fetch 는 API 앱(JWT) 전용.
160
225
  - **컨트롤러에 비즈니스 로직 인라인 금지** (§5.3 One Way) — 여러 모델·
@@ -174,4 +239,5 @@ export default controller({
174
239
  | 결정 24 (E-3 §5) | `this.params` 안전 규칙 (라우트 > body > query · 중복 키 · body/query 탈출구) |
175
240
  | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 — `agents/async.md`) |
176
241
  | 결정 37 | bigint PK 컨트롤러 `String()` 정규화 (`agents/frontend.md`) |
242
+ | 결정 59 | 인증 배선 = `app.config.ts` 의 `session`+`auth(loadUser)` — 없으면 currentUser 영구 null |
177
243
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
@@ -3,7 +3,7 @@ import { pageProps } from 'gaonjs/vue'
3
3
 
4
4
  // home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
5
5
  // 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
6
- const props = pageProps<'home#index'>()
6
+ const props = pageProps<'web:home#index'>()
7
7
  </script>
8
8
 
9
9
  <template>
@@ -21,6 +21,7 @@
21
21
  "vue": "^3.5.0"
22
22
  },
23
23
  "devDependencies": {
24
+ "@types/node": "^22.0.0",
24
25
  "@vitejs/plugin-vue": "^6.0.0",
25
26
  "typescript": "^5.9.0",
26
27
  "vite": "^7.0.0",
@@ -11,7 +11,7 @@
11
11
  "resolveJsonModule": true,
12
12
  "noEmit": true,
13
13
  "jsx": "preserve",
14
- "types": ["node"]
14
+ "types": ["node", "vite/client"]
15
15
  },
16
16
  "include": [
17
17
  "apps/**/*.ts",
@@ -19,7 +19,9 @@
19
19
  "domain/**/*.ts",
20
20
  "shared/**/*.ts",
21
21
  "shared/**/*.vue",
22
- "gaon.config.ts"
22
+ "gaon.config.ts",
23
+ ".gaon/**/*.d.ts",
24
+ "apps/*/.gaon/**/*.d.ts"
23
25
  ],
24
- "exclude": ["node_modules", "dist", ".gaon"]
26
+ "exclude": ["node_modules", "dist"]
25
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.10.2",
3
+ "version": "0.13.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.3.0",
31
- "@gaonjs/core": "0.1.4",
32
- "@gaonjs/data": "0.8.0",
33
- "@gaonjs/mail": "0.1.0",
34
- "@gaonjs/web": "0.5.0",
35
- "@gaonjs/config": "0.2.0"
30
+ "@gaonjs/async": "0.3.1",
31
+ "@gaonjs/config": "0.3.0",
32
+ "@gaonjs/web": "0.5.3",
33
+ "@gaonjs/mail": "0.1.1",
34
+ "@gaonjs/data": "0.8.4",
35
+ "@gaonjs/core": "0.1.4"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""