@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.
- package/dist/doctor/agents-docs-stale.d.ts +2 -0
- package/dist/doctor/agents-docs-stale.js +37 -0
- package/dist/doctor/fixers/index.js +10 -0
- package/dist/doctor/page-fetch.d.ts +8 -0
- package/dist/doctor/page-fetch.js +107 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +13 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +18 -5
- package/dist/scaffold/agentsDocs.d.ts +29 -0
- package/dist/scaffold/agentsDocs.js +115 -0
- package/dist/templates/project/AGENTS.md.tpl +7 -4
- package/dist/templates/project/CLAUDE.md.tpl +2 -1
- package/dist/templates/project/agents/frontend.md.tpl +30 -2
- package/dist/templates/project/agents/realtime.md.tpl +36 -2
- package/package.json +3 -3
|
@@ -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
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -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 정적 검사
|
|
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
|
-
*
|
|
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 정적 검사
|
|
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(정본
|
|
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
|
-
//
|
|
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 ·
|
|
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` 검사
|
|
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. `
|
|
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
|
-
|
|
|
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 # 정적 검사
|
|
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 # 정적 검사
|
|
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
|
-
-
|
|
463
|
-
|
|
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).
|
|
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.
|
|
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.
|
|
39
|
+
"@gaonjs/data": "0.25.4",
|
|
40
40
|
"@gaonjs/mail": "0.5.1",
|
|
41
41
|
"@gaonjs/web": "0.31.2"
|
|
42
42
|
},
|