@gaonjs/cli 0.59.0 → 0.61.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.
@@ -0,0 +1,2 @@
1
+ import type { RuleReport } from './types.js';
2
+ export declare function checkAgentsDocsStale(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,37 @@
1
+ // @gaonjs/cli · doctor · AGENTS 문서 스테일 (결정 449 · 2026-08-07)
2
+ //
3
+ // gaonjs 업그레이드 후에도 프로젝트의 `AGENTS.md`·`agents/*.md` 사본은 스캐폴드
4
+ // 시점 그대로다 — 관례 문서가 낡으면 AI 가 새 표면을 모른 채(또는 폐기된
5
+ // 패턴대로) 코드를 짠다. 설치된 CLI 템플릿(정본)과 byte 비교(저장소
6
+ // agents-docs-sync 게이트와 동일 판정)해 다르면 경고 + 수리 안내를 낸다.
7
+ //
8
+ // 경고(warning)인 이유: 드물게 사용자가 사본에 손댔을 수 있다 — 검사는
9
+ // 표면화만 하고, 덮을지는 사용자가 `gaon g agents-docs`(--check 미리보기)로
10
+ // 결정한다. 2층 구조 미사용 프로젝트(AGENTS.md·agents/ 없음)는 검사 대상
11
+ // 없음 통과(agents-doc-index 와 동일 소급 불강제).
12
+ import { existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { agentsDocsStatus } from '../scaffold/agentsDocs.js';
15
+ export async function checkAgentsDocsStale(cwd) {
16
+ const issues = [];
17
+ // 2층 구조 미사용 — 검사 대상 없음.
18
+ if (!existsSync(join(cwd, 'AGENTS.md')) && !existsSync(join(cwd, 'agents'))) {
19
+ return { rule: 'agents-docs-stale', issues };
20
+ }
21
+ for (const s of agentsDocsStatus(cwd)) {
22
+ if (s.state === 'current')
23
+ continue;
24
+ issues.push({
25
+ rule: 'agents-docs-stale',
26
+ level: 'warning',
27
+ file: s.path,
28
+ message: s.state === 'missing'
29
+ ? `설치된 gaonjs 템플릿에 있는 관례 문서 ${s.path} 가 프로젝트에 없습니다(업그레이드로 추가된 카테고리).\n` +
30
+ `→ gaon g agents-docs 로 생성하세요(미리보기: gaon g agents-docs --check).`
31
+ : `${s.path} 가 설치된 gaonjs 템플릿(정본)과 다릅니다(${s.note ?? '내용 상이'}) — 업그레이드 후 옛 문서가 남으면 AI 가 낡은 관례로 코드를 짭니다.\n` +
32
+ `→ gaon g agents-docs 로 재동기하세요(미리보기: --check · 사본에 직접 적은 내용이 있다면 먼저 CLAUDE.md 등으로 옮기세요 — 이 문서들은 프레임웍 정본 사본입니다).`,
33
+ detail: { state: s.state, note: s.note },
34
+ });
35
+ }
36
+ return { rule: 'agents-docs-stale', issues };
37
+ }
@@ -40,6 +40,11 @@ export const FIXER_CAPABILITIES = [
40
40
  hasFixer: false,
41
41
  note: '수동 · 액션을 두 개로 분리하거나 JSON 분기를 예외/redirect 로 재편(설계 결정).',
42
42
  },
43
+ {
44
+ rule: 'agents-docs-stale',
45
+ hasFixer: false,
46
+ note: '전용 명령 `gaon g agents-docs` 로 재동기(--check 미리보기 · 결정 449) — 재동기 경로를 하나로 둔다(The One Way · --fix 중복 구현 안 함).',
47
+ },
43
48
  {
44
49
  rule: 'n-plus-one',
45
50
  hasFixer: false,
@@ -190,4 +195,9 @@ export const FIXER_CAPABILITIES = [
190
195
  hasFixer: false,
191
196
  note: '수동 · 공유 .env 에서 NODE_ENV 줄을 지우세요 — 모드는 명령이 정합니다(gaon dev=development · gaon serve=production · 결정 430). 지웠을 때 어떤 모드로 돌리려던 것인지는 사람이 알아야 해서 자동 정정하지 않습니다.',
192
197
  },
198
+ {
199
+ rule: 'page-fetch',
200
+ hasFixer: false,
201
+ note: '수동 · raw fetch 를 api()/useForm 으로 바꾸려면 대응 라우트 키(<앱>:<컨트롤러>#<액션>) 추론이 필요해 기계 변환이 불가합니다 — JSON 액션은 api(), 폼은 useForm, 부득이한 커스텀 전송은 readCsrfToken() 탈출구를 쓰세요(결정 453).',
202
+ },
193
203
  ];
@@ -0,0 +1,8 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 소스에서 내부 경로 raw fetch 호출을 찾는다(단위 테스트 진입점 · 주석 제외). */
3
+ export declare function internalFetchCalls(source: string): {
4
+ line: number;
5
+ path: string;
6
+ }[];
7
+ /** 세션 앱(비 JWT)의 .vue 를 훑어 내부 경로 raw fetch 를 경고로 낸다. */
8
+ export declare function checkPageFetch(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,107 @@
1
+ // @gaonjs/cli · doctor · 세션 앱 프론트의 내부 경로 raw fetch 검출 (결정 453 · 경고)
2
+ //
3
+ // 세션 앱의 `.vue` 에서 앱 내부 경로를 raw `fetch('/...')` 로 부르면 CSRF
4
+ // 토큰이 어디에도 실리지 않아 상태 변경 요청(POST/PUT/PATCH/DELETE)이 403
5
+ // 으로 죽는다 — 자동 부착(결정 166·341·342)은 `api()`·`useForm`·`router` 를
6
+ // 탈 때만 작동한다(rooms 샘플 실사용에서 강퇴/위임이 이 경로로 죽었다).
7
+ // csrf-wiring(결정 93)은 서버 세션 배선만 보므로 클라이언트 우회는 이 검사가
8
+ // 잡는다. 폼은 `useForm`, JSON 액션은 `api()` 가 정본(agents/frontend.md §2).
9
+ //
10
+ // 오탐 방지: 첫 인자가 `/` 로 시작하는 **문자열/템플릿 리터럴**일 때만 —
11
+ // 외부 URL(http…)·변수/식 인자(정적 판별 불가)·`//`(프로토콜-상대)는 건드리지
12
+ // 않는다. JWT/API 앱은 토큰 인증이라 REST + fetch 가 정본 경로 — 제외한다.
13
+ import { readdir, readFile } from 'node:fs/promises';
14
+ import { existsSync } from 'node:fs';
15
+ import { join, relative } from 'node:path';
16
+ import { usesJwtStrategy } from './auth-wiring.js';
17
+ // 주석을 공백 치환하되 줄바꿈은 보존(라인 번호 유지) — internal-anchor 와 동형.
18
+ function stripCommentsKeepLines(source) {
19
+ const blank = (m) => m.replace(/[^\n]/g, ' ');
20
+ return source
21
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
22
+ .replace(/<!--[\s\S]*?-->/g, blank)
23
+ .replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
24
+ }
25
+ // `fetch(` 의 첫 인자가 내부 경로 리터럴인 호출만 잡는다. 앞이 식별자/점이면
26
+ // (`myFetch(`·`api.fetch(`) 다른 함수다 — 전역 fetch 호출만 본다.
27
+ const INTERNAL_FETCH = /(?<![.\w$])fetch\s*\(\s*(['"`])(\/(?!\/)[^'"`\n]*)\1/g;
28
+ /** 소스에서 내부 경로 raw fetch 호출을 찾는다(단위 테스트 진입점 · 주석 제외). */
29
+ export function internalFetchCalls(source) {
30
+ const stripped = stripCommentsKeepLines(source);
31
+ const out = [];
32
+ for (const m of stripped.matchAll(INTERNAL_FETCH)) {
33
+ const line = stripped.slice(0, m.index ?? 0).split('\n').length;
34
+ out.push({ line, path: m[2] });
35
+ }
36
+ return out;
37
+ }
38
+ /** 세션 앱(비 JWT)의 .vue 를 훑어 내부 경로 raw fetch 를 경고로 낸다. */
39
+ export async function checkPageFetch(cwd) {
40
+ const appsDir = join(cwd, 'apps');
41
+ const issues = [];
42
+ for (const app of await safeListDirs(appsDir)) {
43
+ // JWT/API 앱은 REST + fetch 가 정본 — 제외(csrf-wiring 과 동일 판정).
44
+ const acPath = join(appsDir, app, 'app.config.ts');
45
+ if (existsSync(acPath)) {
46
+ const acSource = await readFile(acPath, 'utf8').catch(() => '');
47
+ if (usesJwtStrategy(acSource))
48
+ continue;
49
+ }
50
+ for (const abs of await walkVue(join(appsDir, app))) {
51
+ const source = await readFile(abs, 'utf8').catch(() => '');
52
+ const rel = relative(cwd, abs);
53
+ for (const hit of internalFetchCalls(source)) {
54
+ issues.push({
55
+ rule: 'page-fetch',
56
+ level: 'warning',
57
+ file: rel,
58
+ line: hit.line,
59
+ message: `내부 경로 raw fetch 발견: ${rel}:${hit.line} 이 \`fetch('${hit.path}')\` 로 앱 내부를 ` +
60
+ `직접 호출합니다. 세션 앱의 CSRF 토큰 자동 부착(결정 166·341·342)은 \`api()\`·\`useForm\`·` +
61
+ `\`router\` 를 탈 때만 작동해, raw fetch 의 상태 변경 요청은 403 으로 죽습니다(결정 453).\n` +
62
+ `→ JSON 액션(강퇴·위임·좋아요 등 커맨드 포함)은 \`gaonjs/vue\` 의 \`api()\` 로 부르세요: ` +
63
+ `\`await api('<앱>:<컨트롤러>#<액션>', { ...params })\` (agents/frontend.md §2).\n` +
64
+ `→ 폼 제출은 \`useForm(...).post()\`, DELETE 등은 \`router.delete(...)\` (agents/web.md §4).\n` +
65
+ `→ 부득이한 커스텀 전송은 \`readCsrfToken()\` 으로 토큰을 직접 실으세요(탈출구 · 결정 342).`,
66
+ detail: { file: rel, line: hit.line, path: hit.path },
67
+ });
68
+ }
69
+ }
70
+ }
71
+ return { rule: 'page-fetch', issues };
72
+ }
73
+ async function safeListDirs(dir) {
74
+ try {
75
+ const entries = await readdir(dir, { withFileTypes: true });
76
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
77
+ }
78
+ catch {
79
+ return [];
80
+ }
81
+ }
82
+ /** 앱 하위 .vue 절대경로(내림차순 아님 · node_modules/.gaon 제외). */
83
+ async function walkVue(dir) {
84
+ const out = [];
85
+ const walk = async (d) => {
86
+ let entries;
87
+ try {
88
+ entries = await readdir(d, { withFileTypes: true });
89
+ }
90
+ catch {
91
+ return;
92
+ }
93
+ for (const e of entries) {
94
+ const abs = join(d, e.name);
95
+ if (e.isDirectory()) {
96
+ if (e.name === 'node_modules' || e.name === '.gaon')
97
+ continue;
98
+ await walk(abs);
99
+ }
100
+ else if (e.isFile() && e.name.endsWith('.vue')) {
101
+ out.push(abs);
102
+ }
103
+ }
104
+ };
105
+ await walk(dir);
106
+ return out.sort();
107
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'agents-docs-stale' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env' | 'locale-parity' | 'render-return' | 'channel-collision' | 'channel-instance-authorize' | 'dotenv-node-env' | 'page-fetch';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -12,6 +12,7 @@ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.
12
12
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
13
13
  export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
14
14
  export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
15
+ export { checkAgentsDocsStale } from './doctor/agents-docs-stale.js';
15
16
  export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './doctor/column-casing.js';
16
17
  export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
17
18
  export { checkPageFilename } from './doctor/page-filename.js';
@@ -32,7 +33,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
32
33
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
33
34
  */
34
35
  /**
35
- * doctor 정적 검사 31종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
36
+ * doctor 정적 검사 33종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
36
37
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
37
38
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
38
39
  */
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
- * 31 검사를 조립한다:
4
+ * 33 검사를 조립한다:
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 규칙)
@@ -11,6 +11,7 @@
11
11
  * 7) no-auto-import (errata E-5 §2.4 · v0.15 §1.2)
12
12
  * 8) schema-filename (§1.1 · 결정 38 · 스키마 파일명 camelCase)
13
13
  * 9) agents-doc-index (§0 색인 ↔ agents/ 실 파일 · 결정 40)
14
+ * 9b) agents-docs-stale (설치 템플릿 ↔ 프로젝트 사본 byte 비교 · 업그레이드 후 스테일 경고 · 결정 449)
14
15
  * 10) column-casing (§네이밍 · 결정 43·46 · 컬럼 camelCase)
15
16
  * 11) model-filename (§3.4 · 결정 32·46 · 모델 파일명 PascalCase)
16
17
  * 12) page-filename (§3.4 · 결정 32·46 · Vue 페이지 파일명 PascalCase)
@@ -33,6 +34,7 @@
33
34
  * 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
34
35
  * 30) channel-instance-authorize (결정 440 · authorize 없는 인스턴스 채널 = 임의 인스턴스 공개 입장 경고)
35
36
  * 31) dotenv-node-env (결정 430 · 공유 .env 의 NODE_ENV = 모드 누출 경고)
37
+ * 32) page-fetch (결정 453 · 세션 앱 .vue 의 내부 경로 raw fetch = CSRF 미부착 403 경고)
36
38
  *
37
39
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
38
40
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -56,6 +58,7 @@ import { checkSharedPurity } from './doctor/shared-purity.js';
56
58
  import { checkNoAutoImport } from './doctor/no-auto-import.js';
57
59
  import { checkSchemaFilename } from './doctor/schema-filename.js';
58
60
  import { checkAgentsDocIndex } from './doctor/agents-doc-index.js';
61
+ import { checkAgentsDocsStale } from './doctor/agents-docs-stale.js';
59
62
  import { checkColumnCasing } from './doctor/column-casing.js';
60
63
  import { checkModelFilename } from './doctor/model-filename.js';
61
64
  import { checkPageFilename } from './doctor/page-filename.js';
@@ -76,6 +79,7 @@ import { checkLocaleParity } from './doctor/locale-parity.js';
76
79
  import { checkChannelCollision } from './doctor/channel-collision.js';
77
80
  import { checkChannelInstanceAuthorize } from './doctor/channel-instance-authorize.js';
78
81
  import { checkDotenvNodeEnv } from './doctor/dotenv-node-env.js';
82
+ import { checkPageFetch } from './doctor/page-fetch.js';
79
83
  import { renderHuman, renderJson } from './doctor/reporter.js';
80
84
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
81
85
  import { makeResult, } from './doctor/types.js';
@@ -91,6 +95,7 @@ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.
91
95
  export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
92
96
  export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
93
97
  export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
98
+ export { checkAgentsDocsStale } from './doctor/agents-docs-stale.js';
94
99
  export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './doctor/column-casing.js';
95
100
  export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
96
101
  export { checkPageFilename } from './doctor/page-filename.js';
@@ -111,7 +116,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
111
116
  * 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
112
117
  */
113
118
  /**
114
- * doctor 정적 검사 31종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
119
+ * doctor 정적 검사 33종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
115
120
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
116
121
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
117
122
  */
@@ -125,6 +130,7 @@ export const ALL_RULES = [
125
130
  'no-auto-import',
126
131
  'schema-filename',
127
132
  'agents-doc-index',
133
+ 'agents-docs-stale',
128
134
  'column-casing',
129
135
  'model-filename',
130
136
  'page-filename',
@@ -147,6 +153,7 @@ export const ALL_RULES = [
147
153
  'channel-collision',
148
154
  'channel-instance-authorize',
149
155
  'dotenv-node-env',
156
+ 'page-fetch',
150
157
  ];
151
158
  /**
152
159
  * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
@@ -164,6 +171,7 @@ export const RULE_SUMMARIES = {
164
171
  'no-auto-import': '자동 import',
165
172
  'schema-filename': '스키마 파일명',
166
173
  'agents-doc-index': 'AGENTS 색인',
174
+ 'agents-docs-stale': 'AGENTS 문서 스테일',
167
175
  'column-casing': '컬럼 casing',
168
176
  'model-filename': '모델 파일명',
169
177
  'page-filename': '페이지 파일명',
@@ -186,6 +194,7 @@ export const RULE_SUMMARIES = {
186
194
  'channel-collision': '앱간 동명 채널',
187
195
  'channel-instance-authorize': '인스턴스 채널 authorize',
188
196
  'dotenv-node-env': '.env NODE_ENV',
197
+ 'page-fetch': '세션 앱 raw fetch',
189
198
  };
190
199
  const CHECKERS = {
191
200
  'response-mixing': checkResponseMixing,
@@ -197,6 +206,7 @@ const CHECKERS = {
197
206
  'no-auto-import': checkNoAutoImport,
198
207
  'schema-filename': checkSchemaFilename,
199
208
  'agents-doc-index': checkAgentsDocIndex,
209
+ 'agents-docs-stale': checkAgentsDocsStale,
200
210
  'column-casing': checkColumnCasing,
201
211
  'model-filename': checkModelFilename,
202
212
  'page-filename': checkPageFilename,
@@ -219,6 +229,7 @@ const CHECKERS = {
219
229
  'channel-collision': checkChannelCollision,
220
230
  'channel-instance-authorize': checkChannelInstanceAuthorize,
221
231
  'dotenv-node-env': checkDotenvNodeEnv,
232
+ 'page-fetch': checkPageFetch,
222
233
  };
223
234
  /**
224
235
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
package/dist/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export { runTestCommand, type TestCommandOptions, type TestScope } from "./comma
16
16
  export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, type AuthScaffoldOptions, type ScaffoldFile, type ScaffoldResult, type GenerateAuthOptions, } from "./generate.js";
17
17
  export { writeUiKitScaffold, writeUiKitFiles, uiKitScaffoldFiles, authUiKitFiles, runGenerateUiKitCommand, type UiKitScaffoldOptions, type UiKitScaffoldFile, type UiKitScaffoldResult, type GenerateUiKitOptions, } from "./uikit.js";
18
18
  export { runGenerateCommand, planScaffold, parseGenerateArgs, type GenerateType, type GenerateOptions, type GenerateResult, } from "./commands/g.js";
19
+ export { runAgentsDocsCommand, agentsDocsStatus, agentsDocsTemplates, type AgentsDocsCommandOptions, type AgentsDocStatus, type AgentsDocState, } from "./scaffold/agentsDocs.js";
19
20
  export { runHubCommand, type HubCommandOptions } from "./hub.js";
20
21
  export { runServeCommand, type ServeCommandOptions } from "./serve.js";
21
22
  export { computeHealth, DEV_HEALTH_PATH, type DevHealthContext, type GaonHealth, } from "./dev/health.js";
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@ import { runConsoleCommand } from "./commands/console.js";
22
22
  import { runTestCommand } from "./commands/test.js";
23
23
  import { runGenerateAuthCommand } from "./generate.js";
24
24
  import { runGenerateUiKitCommand } from "./uikit.js";
25
+ import { runAgentsDocsCommand } from "./scaffold/agentsDocs.js";
25
26
  import { runGenerateCommand } from "./commands/g.js";
26
27
  import { runHubCommand } from "./hub.js";
27
28
  import { runWorkCommand } from "./work.js";
@@ -43,6 +44,8 @@ export { runTestCommand } from "./commands/test.js";
43
44
  export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, } from "./generate.js";
44
45
  export { writeUiKitScaffold, writeUiKitFiles, uiKitScaffoldFiles, authUiKitFiles, runGenerateUiKitCommand, } from "./uikit.js";
45
46
  export { runGenerateCommand, planScaffold, parseGenerateArgs, } from "./commands/g.js";
47
+ // 결정 449: AGENTS 문서 재동기 — 업그레이드 후 스테일 사본을 설치 템플릿으로.
48
+ export { runAgentsDocsCommand, agentsDocsStatus, agentsDocsTemplates, } from "./scaffold/agentsDocs.js";
46
49
  export { runHubCommand } from "./hub.js";
47
50
  export { runServeCommand } from "./serve.js";
48
51
  // dev 전용 라이브 헬스(결정 69). serve(--dev)가 등록하고, 브라우저 e2e 층이
@@ -126,6 +129,7 @@ function renderHelp(version = VERSION) {
126
129
  " gaon g job <Name> 비동기 잡 (domain/jobs · later/in/at)",
127
130
  " gaon g app <name> 앱 스캐폴드 (apps/<name>/ · routes·controllers·pages·layouts)",
128
131
  " gaon g channel <name> 실시간 채널 (정의 + 클라 컴포저블 · --instance = 파라미터화 채널 = 매치·스레드별 동적 방)",
132
+ " gaon g agents-docs AGENTS.md·agents/*.md 를 설치된 gaonjs 정본 템플릿으로 재동기 (업그레이드 후 · 멱등 · --check = 미리보기)",
129
133
  " gaon g <type> --overwrite 기존 파일 덮어쓰기 · --app <이름> · --json",
130
134
  " gaon mcp 내장 MCP 서버 · AI 도구 7종 (list_routes·get_schema·run_migration·run_tests·read_agent_doc·run_check·run_doctor · --http)",
131
135
  " gaon hub 실시간 허브 프로세스 (프레즌스 권위·중계 · 리더 선출 HA)",
@@ -152,7 +156,7 @@ function renderHelp(version = VERSION) {
152
156
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
153
157
  */
154
158
  export function parseDoctorChecks(argv) {
155
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 31종)를 단일 출처로 쓴다 — 과거
159
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 33종)를 단일 출처로 쓴다 — 과거
156
160
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
157
161
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
158
162
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -173,7 +177,7 @@ export function parseDoctorChecks(argv) {
173
177
  }
174
178
  // 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
175
179
  // 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
176
- // 31종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
180
+ // 33종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
177
181
  if (unknown.length > 0) {
178
182
  process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
179
183
  ` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
@@ -493,7 +497,7 @@ export function runCli(argv, opts = {}) {
493
497
  });
494
498
  return;
495
499
  }
496
- // `gaon doctor` — 정적 검사(M9-E · 31 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
500
+ // `gaon doctor` — 정적 검사(M9-E · 33 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
497
501
  // 선택 실행, --json 은 자동화 파싱용.
498
502
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
499
503
  if (argv[0] === "doctor") {
@@ -650,6 +654,15 @@ export function runCli(argv, opts = {}) {
650
654
  process.exitCode = code;
651
655
  return;
652
656
  }
657
+ // 결정 449: 업그레이드 후 AGENTS 문서 재동기 — 이름 인자 없는 문서 제너레이터.
658
+ if (argv[1] === "agents-docs") {
659
+ const code = runAgentsDocsCommand({
660
+ check: argv.includes("--check"),
661
+ json: argv.includes("--json"),
662
+ });
663
+ process.exitCode = code;
664
+ return;
665
+ }
653
666
  const known = ["controller", "model", "page", "job", "app", "channel"];
654
667
  const type = argv[1];
655
668
  if (type && known.includes(type)) {
@@ -701,8 +714,8 @@ export function runCli(argv, opts = {}) {
701
714
  return;
702
715
  }
703
716
  process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
704
- ` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app | channel\n` +
705
- ` → 옵션: --app <이름> · --overwrite · --json · --instance (channel 전용)\n`);
717
+ ` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app | channel | agents-docs\n` +
718
+ ` → 옵션: --app <이름> · --overwrite · --json · --instance (channel 전용) · --check (agents-docs 전용)\n`);
706
719
  process.exitCode = 1;
707
720
  return;
708
721
  }
@@ -0,0 +1,29 @@
1
+ /** 동기 대상 문서 하나 — path 는 프로젝트 루트 기준 상대 경로(POSIX). */
2
+ export interface AgentsDocTemplate {
3
+ readonly path: string;
4
+ readonly contents: string;
5
+ }
6
+ /** 파일 하나의 동기 상태. */
7
+ export type AgentsDocState = 'current' | 'stale' | 'missing';
8
+ export interface AgentsDocStatus {
9
+ readonly path: string;
10
+ readonly state: AgentsDocState;
11
+ /** stale 일 때 사람용 요지(줄 수 변화). current·missing 은 undefined. */
12
+ readonly note?: string;
13
+ }
14
+ /**
15
+ * 설치된 CLI 템플릿에서 동기 대상 문서 전수를 읽는다 — AGENTS.md +
16
+ * agents/*.md(템플릿 폴더 실측 · 카테고리가 늘면 자동 추종). 토큰 치환은
17
+ * 하지 않는다(이 문서들은 무토큰 원문 — 저장소 테스트가 고정).
18
+ */
19
+ export declare function agentsDocsTemplates(): AgentsDocTemplate[];
20
+ /** 프로젝트 사본 ↔ 설치 템플릿의 파일별 상태(byte 비교 · 게이트와 동일 판정). */
21
+ export declare function agentsDocsStatus(cwd: string): AgentsDocStatus[];
22
+ export interface AgentsDocsCommandOptions {
23
+ readonly cwd?: string;
24
+ /** 미리보기 — 상태만 보고하고 쓰지 않는다. 스테일·누락이 있으면 exit 1. */
25
+ readonly check?: boolean;
26
+ readonly json?: boolean;
27
+ }
28
+ /** `gaon g agents-docs` 본체. 반환 = exit code. */
29
+ export declare function runAgentsDocsCommand(opts?: AgentsDocsCommandOptions): number;
@@ -0,0 +1,115 @@
1
+ // @gaonjs/cli · `gaon g agents-docs` — AGENTS 문서 재동기 (결정 449)
2
+ //
3
+ // gaonjs 업그레이드 후 프로젝트의 `AGENTS.md`·`agents/*.md` 사본이 옛 템플릿
4
+ // 채로 남으면, AI 가 낡은 관례 문서를 읽고 새 표면(예: MemberLeft)을 모른 채
5
+ // 코드를 짠다 — rooms 샘플 2회차가 손 복사로 때운 실사용 갭. 이 명령이
6
+ // 설치된 CLI 의 정본 템플릿으로 사본을 덮어 재동기한다(멱등 · 이미 최신이면
7
+ // no-op).
8
+ //
9
+ // 동기 대상 = AGENTS.md + agents/*.md **만**이다. 이 문서들은 프레임웍 정본의
10
+ // 사본이고(저장소 게이트가 byte 동일성을 강제 · 결정 40) 스캐폴드 토큰도
11
+ // 없어({{TOKEN}} 금지 테스트) byte 비교·통째 덮기가 안전하다. CLAUDE.md 는
12
+ // 제외 — 프로젝트 이름이 치환되고 사용자가 프로젝트 규칙을 덧붙이는 소유
13
+ // 파일이라 덮으면 사용자 내용이 사라진다(결정 449 기각 대안 참조).
14
+ //
15
+ // 안전장치: `--check` 는 쓰지 않고 상태만 보고(스테일이 있으면 exit 1 —
16
+ // CI 에서 드리프트 감지용), 덮을 때는 파일별로 무엇이 바뀌는지 출력한다.
17
+ // doctor `agents-docs-stale` 검사가 같은 byte 비교로 스테일을 표면화한다.
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { templateDir } from '../templates/index.js';
21
+ /**
22
+ * 설치된 CLI 템플릿에서 동기 대상 문서 전수를 읽는다 — AGENTS.md +
23
+ * agents/*.md(템플릿 폴더 실측 · 카테고리가 늘면 자동 추종). 토큰 치환은
24
+ * 하지 않는다(이 문서들은 무토큰 원문 — 저장소 테스트가 고정).
25
+ */
26
+ export function agentsDocsTemplates() {
27
+ const root = templateDir();
28
+ const out = [
29
+ { path: 'AGENTS.md', contents: readFileSync(join(root, 'AGENTS.md.tpl'), 'utf8') },
30
+ ];
31
+ const agentsDir = join(root, 'agents');
32
+ for (const f of readdirSync(agentsDir).sort()) {
33
+ if (!f.endsWith('.md.tpl'))
34
+ continue;
35
+ out.push({
36
+ path: `agents/${f.slice(0, -'.tpl'.length)}`,
37
+ contents: readFileSync(join(agentsDir, f), 'utf8'),
38
+ });
39
+ }
40
+ return out;
41
+ }
42
+ const lineCount = (s) => s.split('\n').length;
43
+ /** 프로젝트 사본 ↔ 설치 템플릿의 파일별 상태(byte 비교 · 게이트와 동일 판정). */
44
+ export function agentsDocsStatus(cwd) {
45
+ return agentsDocsTemplates().map(({ path, contents }) => {
46
+ const abs = join(cwd, path);
47
+ if (!existsSync(abs))
48
+ return { path, state: 'missing' };
49
+ const current = readFileSync(abs, 'utf8');
50
+ if (current === contents)
51
+ return { path, state: 'current' };
52
+ return {
53
+ path,
54
+ state: 'stale',
55
+ note: `프로젝트 ${lineCount(current)}줄 ↔ 템플릿 ${lineCount(contents)}줄`,
56
+ };
57
+ });
58
+ }
59
+ /** `gaon g agents-docs` 본체. 반환 = exit code. */
60
+ export function runAgentsDocsCommand(opts = {}) {
61
+ const cwd = opts.cwd ?? process.cwd();
62
+ // 대상 프로젝트 판별 — AGENTS.md 조차 없는 폴더에 문서를 흩뿌리지 않는다.
63
+ // (agents/ 만 있는 변칙도 gaon 프로젝트로 본다 — 어느 쪽도 없으면 안내 후 종료.)
64
+ if (!existsSync(join(cwd, 'AGENTS.md')) && !existsSync(join(cwd, 'agents'))) {
65
+ process.stderr.write(` ✗ gaon g agents-docs: 여기는 gaon 프로젝트가 아닙니다(AGENTS.md·agents/ 없음).\n` +
66
+ ` → 프로젝트 루트에서 실행하세요. 새 프로젝트는 gaon new <name> 이 문서까지 스캐폴드합니다.\n`);
67
+ return 1;
68
+ }
69
+ const statuses = agentsDocsStatus(cwd);
70
+ const stale = statuses.filter((s) => s.state === 'stale');
71
+ const missing = statuses.filter((s) => s.state === 'missing');
72
+ const outdated = [...stale, ...missing];
73
+ if (opts.check) {
74
+ if (opts.json) {
75
+ process.stdout.write(JSON.stringify({ check: true, wrote: [], files: statuses }, null, 2) + '\n');
76
+ return outdated.length > 0 ? 1 : 0;
77
+ }
78
+ if (outdated.length === 0) {
79
+ process.stdout.write(` ✓ agents 문서 ${statuses.length}개 모두 설치 템플릿과 동일(최신)입니다.\n`);
80
+ return 0;
81
+ }
82
+ for (const s of statuses) {
83
+ if (s.state === 'current')
84
+ continue;
85
+ process.stdout.write(s.state === 'missing'
86
+ ? ` ! 누락: ${s.path} — 설치 템플릿에 있는데 프로젝트에 없습니다\n`
87
+ : ` ! 스테일: ${s.path} (${s.note})\n`);
88
+ }
89
+ process.stdout.write(` → ${outdated.length}개가 설치 템플릿과 다릅니다. 재동기: gaon g agents-docs (이 문서들은 프레임웍 정본 사본 — 손댄 내용이 있다면 먼저 옮기세요)\n`);
90
+ return 1;
91
+ }
92
+ const wrote = [];
93
+ for (const s of outdated) {
94
+ const tpl = agentsDocsTemplates().find((t) => t.path === s.path);
95
+ const abs = join(cwd, s.path);
96
+ mkdirSync(dirname(abs), { recursive: true });
97
+ writeFileSync(abs, tpl.contents);
98
+ wrote.push(s.path);
99
+ }
100
+ if (opts.json) {
101
+ process.stdout.write(JSON.stringify({ check: false, wrote, files: agentsDocsStatus(cwd) }, null, 2) + '\n');
102
+ return 0;
103
+ }
104
+ if (wrote.length === 0) {
105
+ process.stdout.write(` ✓ agents 문서 ${statuses.length}개 모두 이미 최신 — 변경 없음(멱등).\n`);
106
+ return 0;
107
+ }
108
+ for (const s of outdated) {
109
+ process.stdout.write(s.state === 'missing'
110
+ ? ` + 생성: ${s.path}\n`
111
+ : ` ~ 재동기: ${s.path} (${s.note})\n`);
112
+ }
113
+ process.stdout.write(` ✓ ${wrote.length}개 문서를 설치 템플릿(정본)으로 재동기했습니다 — ${statuses.length - wrote.length}개는 이미 최신.\n`);
114
+ return 0;
115
+ }
@@ -113,7 +113,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
113
113
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
114
114
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
115
115
 
116
- ### 2.2 `gaon doctor` 검사 31
116
+ ### 2.2 `gaon doctor` 검사 33
117
117
 
118
118
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
119
119
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -145,7 +145,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
145
145
  28. `render-return` — 액션이 `this.render`/`this.redirect`/`this.json` 을 호출만 하고 `return` 하지 않음 = 응답이 버려져 조용히 204(백지) — `return this.render(...)` 로 고치라 (결정 340 · 경고)
146
146
  29. `channel-collision` — 두 앱이 **같은 이름의 채널**을 각각 정의 = **에러**. 채널 이름은 전역이다(브로드캐스트 subject `gaon.chan.<이름>`·프레즌스 키에 앱 프리픽스 없음) — 한 앱의 broadcast 가 다른 앱 연결로 팬아웃되고 접속자 목록이 병합되며, 두 정의의 `authorize` 가 갈리면 공개 쪽 규칙으로 메시지가 샌다. 앱마다 이름을 분리하거나(클라이언트 `useChannel` 인자도 함께), 일부러 공유하는 채널이면 정의를 `shared/channels/<이름>.ts` 하나에 두고 각 앱 채널 파일에서 재수출하라(재수출은 통과 · 정의 하나 = 인가 규칙 하나) — 잡·리스너의 동명 등록 throw(결정 271)와 같은 계열의 정적 검사 (`agents/realtime.md` §2)
147
147
  30. `channel-instance-authorize` — `instance: true` 채널(파라미터화 채널 · 결정 440)에 `authorize` 가 없음 = **경고**. 인스턴스 채널은 임의 문자열 키로 무한 실행 인스턴스(`/gaon/ws/<이름>/<인스턴스>`)가 열리므로, authorize 가 없으면 누구나 아무 인스턴스에나 입장한다. 매치·스레드처럼 참가자가 정해진 채널이면 `authorize(ctx)` 에서 `ctx.instance` 로 입장을 판정하라 — 공개 관전형(누구나 입장)이 의도면 무시해도 된다(재수출 정의는 대상 모듈을 따라가 판정 · `agents/realtime.md` §2.7)
148
- 31. `dotenv-node-env` — 공유 `.env`(·`.env.local`·`.env.example`)`NODE_ENV` 설정됨 = **경고**. **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production(결정 430). 파일들은 개발·운영이 함께 읽으므로 값을 박으면 모드가 양쪽으로 샌다: `production` 이면 `gaon dev` 쿠키 Secure·dev 플레이스홀더 secret 거부로 죽고, `development` 면 운영 `gaon serve` 에서 프로덕션 안전장치(플레이스홀더 secret 거부·쿠키 Secure·락 in-memory 폴백 차단)가 통째로 꺼진다(**부팅은 green, 보안만 꺼짐**). `.env` 에서 줄을 지우고, 모드별 값이 필요하면 `.env.development`/`.env.production` 오버레이에, 일회성이면 명령 앞에 붙인다(`NODE_ENV=production gaon serve`)모드별 오버레이 파일은 검사 대상이 아니다 (결정 430)
148
+ 31. `agents-docs-stale` — 문서(`AGENTS.md`)·`agents/*.md` 사본이 **설치된 gaonjs 템플릿(정본)과 다름** = **경고**(byte 비교). gaonjs 업그레이드 관례 문서가 채로 남으면 AI낡은 관례·없는 표면으로 코드를 짠다 `gaon g agents-docs` 재동기하라(미리보기 `--check` · 멱등 · 사본에 직접 적은 내용은 프로젝트 소유 문서(CLAUDE.md)로 옮긴 문서들은 프레임웍 정본 사본이다) (결정 449)
149
+ 32. `dotenv-node-env` — 공유 `.env`(·`.env.local`·`.env.example`)에 `NODE_ENV` 가 설정됨 = **경고**. **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production(결정 430). 이 파일들은 개발·운영이 함께 읽으므로 값을 박으면 모드가 양쪽으로 샌다: `production` 이면 `gaon dev` 가 쿠키 Secure·dev 플레이스홀더 secret 거부로 죽고, `development` 면 운영 `gaon serve` 에서 프로덕션 안전장치(플레이스홀더 secret 거부·쿠키 Secure·락 in-memory 폴백 차단)가 통째로 꺼진다(**부팅은 green, 보안만 꺼짐**). `.env` 에서 그 줄을 지우고, 모드별 값이 필요하면 `.env.development`/`.env.production` 오버레이에, 일회성이면 명령 앞에 붙인다(`NODE_ENV=production gaon serve`) — 모드별 오버레이 파일은 검사 대상이 아니다 (결정 430)
150
+ 33. `page-fetch` — 세션 앱 `.vue` 가 앱 내부 경로를 raw `fetch('/...')` 로 호출 = **경고**. CSRF 토큰 자동 부착(결정 166·341·342)은 `api()`·`useForm`·`router` 를 탈 때만 작동해, raw fetch 의 상태 변경 요청(POST/PUT/PATCH/DELETE)은 403 으로 죽는다(rooms 샘플 실사용에서 강퇴/위임이 이 경로로 죽었다). JSON 액션(강퇴·위임·좋아요 등 커맨드 포함)은 `api()`, 폼은 `useForm`, 부득이한 커스텀 전송은 `readCsrfToken()` 탈출구 — 첫 인자가 `/` 로 시작하는 문자열/템플릿 리터럴만 검출(외부 URL·변수 인자 오탐 제외), JWT/API 앱은 REST + fetch 가 정본이라 제외 (결정 453 · `agents/frontend.md` §2)
149
151
 
150
152
  ## 3. 로직 배치 One Way 판단표
151
153
 
@@ -170,7 +172,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
170
172
  |---|---|---|
171
173
  | 지금 페이지의 데이터를 다시 받기 (필터 변경·새로고침·무한 스크롤) | **Inertia partial reload** — 같은 액션 재호출, 필요한 props만 | §6.1 |
172
174
  | 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) — 서버 개시는 `broadcast(name,data)`, 클라 메시지 응답은 `ctx.broadcast` | §7 |
173
- | 페이지와 무관한 데이터 요청 (자동완성·옵션 조회 앱 내부용) | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md`) | E-3 |
175
+ | 페이지 리로드가 필요 없는 앱 내부 요청 — 조회(자동완성·옵션)든 **상태 변경 커맨드(강퇴·위임·좋아요·토글)**든 | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md` · CSRF 자동 부착) | E-3 · 결정 452 |
174
176
  | 외부에 공개하는 API (모바일 앱·서드파티) | **별도 API 앱 + JWT 옵션** | §3, §7 |
175
177
 
176
178
  ### 3.4 잡 발행 위치 (결정 32)
@@ -206,7 +208,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
206
208
  ```bash
207
209
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
208
210
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
209
- gaon doctor # 정적 검사 31종 (§2.2)
211
+ gaon doctor # 정적 검사 33종 (§2.2)
210
212
  ```
211
213
 
212
214
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -218,6 +220,7 @@ gaon doctor # 정적 검사 31종 (§2.2)
218
220
  | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
219
221
  | `gaon g <type> <name>` | 스캐폴드: `auth`·`ui-kit`·`controller`·`model`·`page`·`job`·`app`·`channel` · `g auth --app <앱> --public` = 비-web 앱에 공개 회원가입(`/registration/new`)을 opt-in(기본: web=공개·비-web=역할 게이트 · 결정 155) · `g channel <이름> [--instance]` = 실시간 채널(정의 + 클라 컴포저블 · `--instance` = 파라미터화 채널 = 매치·스레드별 동적 방 · 결정 440·442 · `agents/realtime.md` §2.7) |
220
222
  | `gaon g auth --jwt --app <앱>` | **API(JWT) 앱 변형** — 이 명령 **하나**가 앱 폴더째 만든다(토큰 컨트롤러 3종 + `strategy:'jwt'` app.config + `<APP>_JWT_SECRET` 시드 · 페이지·UI 킷·회원가입 없음). `gaon g app` 을 먼저 돌리지 않는다 — 이미 있는 app.config 는 자동 배선을 못 해 exit 1 이고, 쓰지 않는 Vue 프론트가 남는다. web 앱은 세션이 정본이라 `--jwt` 불가 (결정 337·338) |
223
+ | `gaon g agents-docs` | **gaonjs 업그레이드 후 이 문서들(`AGENTS.md`·`agents/*.md`)을 설치된 정본 템플릿으로 재동기** — 멱등(이미 최신 = no-op) · `--check` = 쓰지 않고 스테일 목록만(스테일 있으면 exit 1 · CI 드리프트 감지) · `--json`. doctor `agents-docs-stale`(§2.2) 이 스테일을 경고로 표면화한다 (결정 449) |
221
224
  | `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
222
225
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
223
226
  | `gaon check` / `test` / `doctor` | 검증 루프 |
@@ -86,7 +86,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
86
86
 
87
87
  ```bash
88
88
  gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
89
- gaon doctor # 정적 검사 31종 (상세 AGENTS §2.2)
89
+ gaon doctor # 정적 검사 33종 (상세 AGENTS §2.2)
90
90
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
91
91
  ```
92
92
 
@@ -113,6 +113,7 @@ gaon g model <Name> # 스키마 + 모델 스캐폴드 (E-4)
113
113
  gaon g page <Path>/<Name> # Vue 페이지 (Inertia SPA · pageProps)
114
114
  gaon g job <Name> # 비동기 잡
115
115
  gaon g auth # 인증 스캐폴드 (세션 + JWT 옵션)
116
+ gaon g agents-docs # gaonjs 업그레이드 후 AGENTS.md·agents/*.md 를 정본 템플릿으로 재동기 (--check 미리보기)
116
117
  gaon db diff # 스키마 ↔ DB 차이 (적용 X)
117
118
  gaon db migrate # 실제 적용 + _gaon_migrations 이력
118
119
  gaon db seed # domain/seed.ts 실행
@@ -114,6 +114,30 @@ async function search(q: string) {
114
114
  </script>
115
115
  ```
116
116
 
117
+ **상태 변경 커맨드도 같은 경로다(결정 452)** — 강퇴·위임·좋아요·토글처럼
118
+ 페이지의 버튼이 서버 상태를 바꾸는 JSON 액션 호출도 `api()` 가 정본이다
119
+ (폼이 아니므로 `useForm` 이 아니고, `fetch()` 는 CSRF 미부착으로 403 — 아래
120
+ 함정). POST 라우트 `r.post('/posts/:id/like', 'posts#like')` 기준:
121
+
122
+ ```vue
123
+ <script setup lang="ts">
124
+ import { api, isApiError } from 'gaonjs/vue'
125
+
126
+ async function like(postId: string) {
127
+ try {
128
+ // :id 자리표시자는 params 에서 채워지고, 남는 값은 JSON 본문으로 실린다.
129
+ // CSRF 토큰은 프레임웍이 X-CSRF-Token 헤더로 자동 부착한다(결정 166·341).
130
+ const { likes } = await api('web:posts#like', { id: postId })
131
+ return likes
132
+ } catch (e) {
133
+ // 4xx/5xx 는 ApiError throw — res.ok 검사가 아니라 catch 로 받는다(결정 304).
134
+ if (isApiError(e) && e.status === 403) return alert('권한이 없습니다.')
135
+ throw e
136
+ }
137
+ }
138
+ </script>
139
+ ```
140
+
117
141
  - **시그니처** — `api(key, params?, opts?)`. 제네릭 타입 인자를 직접
118
142
  붙이지 않는다 — `key` 값 자체가 `keyof GaonRouteMap` 으로 좁혀져
119
143
  반환 타입을 결정한다 (`packages/vue/src/api.ts`).
@@ -459,8 +483,12 @@ async function runSearch(q: string) {
459
483
  결정한다.
460
484
  - **shared 컴포넌트/컴포저블에서 `pageProps`/`api` 호출·domain 값 import 금지** —
461
485
  doctor **shared-purity** 위반(`.ts`·`.vue` 공통 · 결정 217). 데이터는 props/인자로.
462
- - **Vue 페이지에서 `fetch()` 구현 금지** 세션 앱 폼은
463
- `gaonjs/vue` `useForm(...).post()` (`agents/web.md` §4 · 결정 64).
486
+ - **세션 페이지에서 `fetch()` 금지 폼이든 버튼 액션이든**(결정 452)
487
+ 폼은 `useForm(...).post()` (`agents/web.md` §4 · 결정 64), 강퇴·위임·좋아요
488
+ 같은 상태 변경 커맨드는 `api()` (§2). raw `fetch()` 는 CSRF 토큰이 안 실려
489
+ 상태 변경 요청(POST/PUT/PATCH/DELETE)이 **403 으로 죽는다** — 자동 부착
490
+ (결정 166·341·342)은 `api()`·`useForm`·`router` 를 탈 때만 작동한다.
491
+ REST + `fetch()` 는 API 앱(JWT) 전용이다.
464
492
  - **내부 경로 일반 `<a href="/...">` 금지** — 클릭마다 전체 문서를 다시
465
493
  로드해 SPA 상태가 초기화된다. 앱 내부 이동은 `gaonjs/vue` 의 `Link`
466
494
  (`<Link href="/...">`) 또는 `router.visit(...)`, 외부 URL·`target="_blank"`
@@ -340,16 +340,21 @@ import { Room } from '../models/Room.js'
340
340
 
341
341
  export default on(InstanceClosed, async ({ channel, instance }) => {
342
342
  if (channel !== 'match') return
343
- await Room.where('key', '=', instance).delete()
343
+ await Room.where('key', '=', instance).deleteAll()
344
344
  broadcast('lobby', { type: 'room-closed', key: instance })
345
345
  })
346
346
  ```
347
347
 
348
+ - **체인에 `.delete()` 는 없다** — 단건은 `rec.delete()` · 벌크는 체인
349
+ `deleteAll()` (`agents/data.md` §벌크 계약). `where(...).delete()` 는 컴파일이
350
+ 통과하는 것처럼 보여도 런타임 TypeError 로 리스너가 죽고, 재시도 소진 후
351
+ 이벤트가 폐기돼 **방이 영영 안 지워진다**(rooms 샘플 실측 · 결정 451).
348
352
  - **핸들러는 워커 딱 하나에서만 돈다** — 이벤트 스트림(JetStream)의 리스너
349
353
  durable 컨슈머를 전 `gaon work` 가 공유하므로, 여러 워커를 띄워도 이벤트당
350
354
  한 워커만 처리한다(개발자가 서버를 고르지 않는다). at-least-once 라
351
355
  **핸들러는 멱등하게**(이벤트 배터리 공통 관례 — 위 예시의 create 는 key
352
- unique + upsert 또는 존재 검사로 감싸는 것이 안전하다).
356
+ unique + upsert 또는 존재 검사로 감싸는 것이 안전하고, closed 쪽 `deleteAll`
357
+ 은 0행 삭제 = no-op 이라 그 자체로 멱등이다).
353
358
  - **일시적 방 vs 영속 방** — 일시적 방(익명 대화방 등)은 이 두 이벤트가 곧
354
359
  생성/삭제다. 영속 방(게임 매치·게시물 스레드)은 방 row 를 서비스로 먼저
355
360
  만들고(DB = 진실 원천 · 참가 authorize 도 그 row 로) 이 이벤트는 **점유
@@ -426,6 +431,35 @@ present 대상 수(0 = 부재 = no-op 멱등). **누가 kick 할 수 있는가
426
431
  await kick('room', `user:${targetId}`, { instance: roomId, reason: '규정 위반' })
427
432
  ```
428
433
 
434
+ **버튼 → 컨트롤러 → `api()` 완결 경로(결정 452).** kick 은 서버 전용
435
+ 표면이라 페이지의 강퇴 버튼은 **JSON 액션을 `api()` 로** 부른다 — 폼이
436
+ 아니므로 `useForm` 이 아니고, raw `fetch()` 는 CSRF 미부착으로 403 이다
437
+ (`agents/frontend.md` §2). 위임(delegate)·방 설정 변경류 **방장 커맨드도
438
+ 전부 같은 경로**다:
439
+
440
+ ```ts
441
+ // apps/web/routes.ts
442
+ r.post('/rooms/:key/kick', 'rooms#kick')
443
+
444
+ // apps/web/controllers/rooms.ts — 인가(방장 검사) → kick → this.json
445
+ async kick() {
446
+ this.requireAuth()
447
+ const { key, targetUserId } = this.params({ _row: {} as { key: string; targetUserId: string } })
448
+ const room = await Room.where('key', '=', key).first()
449
+ if (!room) return this.json({ message: '존재하지 않는 방입니다.' }, 404)
450
+ if (String(room.ownerId) !== String((this.auth.user as { id: bigint }).id))
451
+ return this.json({ message: '방장만 강퇴할 수 있습니다.' }, 403)
452
+ await kick('room', `user:${targetUserId}`, { instance: key, reason: '방장에 의해 강퇴되었습니다.' })
453
+ return this.json({ success: true })
454
+ }
455
+ ```
456
+
457
+ ```ts
458
+ // apps/web/pages/Rooms/Show.vue — CSRF 는 api() 가 자동 부착(결정 166·341)
459
+ import { api, isApiError } from 'gaonjs/vue'
460
+ await api('web:rooms#kick', { key: room.key, targetUserId }) // :key 는 자리표시자, 나머지는 JSON 바디
461
+ ```
462
+
429
463
  - 마지막 멤버를 kick 하면 `InstanceClosed` 가 **자동 발화**한다(§2.8 앵커
430
464
  그대로 · 특례 없음).
431
465
  - 전달은 at-most-once(즉시성 도구) — **영구 차단 보증은 ban 패턴(③)이
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.59.0",
3
+ "version": "0.61.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,10 +33,10 @@
33
33
  "typescript": "^5.9.0",
34
34
  "vite": "^7.0.0",
35
35
  "@gaonjs/async": "0.22.0",
36
+ "@gaonjs/config": "0.25.7",
36
37
  "@gaonjs/core": "0.3.0",
37
- "@gaonjs/config": "0.25.6",
38
38
  "@gaonjs/i18n": "0.3.1",
39
- "@gaonjs/data": "0.25.3",
39
+ "@gaonjs/data": "0.25.4",
40
40
  "@gaonjs/mail": "0.5.1",
41
41
  "@gaonjs/web": "0.31.2"
42
42
  },