@gaonjs/cli 0.58.1 → 0.60.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 +5 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +2 -1
- package/dist/doctor.js +8 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +39 -6
- package/dist/scaffold/agentsDocs.d.ts +29 -0
- package/dist/scaffold/agentsDocs.js +115 -0
- package/dist/scaffold/channel.js +3 -0
- package/dist/templates/project/AGENTS.md.tpl +5 -3
- package/dist/templates/project/CLAUDE.md.tpl +2 -1
- package/dist/templates/project/agents/realtime.md.tpl +98 -25
- package/package.json +6 -6
|
@@ -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,
|
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';
|
|
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 정적 검사 32종의 정본 목록(§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
|
+
* 32 검사를 조립한다:
|
|
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)
|
|
@@ -56,6 +57,7 @@ import { checkSharedPurity } from './doctor/shared-purity.js';
|
|
|
56
57
|
import { checkNoAutoImport } from './doctor/no-auto-import.js';
|
|
57
58
|
import { checkSchemaFilename } from './doctor/schema-filename.js';
|
|
58
59
|
import { checkAgentsDocIndex } from './doctor/agents-doc-index.js';
|
|
60
|
+
import { checkAgentsDocsStale } from './doctor/agents-docs-stale.js';
|
|
59
61
|
import { checkColumnCasing } from './doctor/column-casing.js';
|
|
60
62
|
import { checkModelFilename } from './doctor/model-filename.js';
|
|
61
63
|
import { checkPageFilename } from './doctor/page-filename.js';
|
|
@@ -91,6 +93,7 @@ export { inspectSharedSource, checkSharedPurity, } from './doctor/shared-purity.
|
|
|
91
93
|
export { inspectConfigForAutoImport, inspectPackageJson, checkNoAutoImport, } from './doctor/no-auto-import.js';
|
|
92
94
|
export { expectedSchemaStem, extractTableName, checkSchemaFilename, } from './doctor/schema-filename.js';
|
|
93
95
|
export { extractAgentDocRefs, checkAgentsDocIndex } from './doctor/agents-doc-index.js';
|
|
96
|
+
export { checkAgentsDocsStale } from './doctor/agents-docs-stale.js';
|
|
94
97
|
export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './doctor/column-casing.js';
|
|
95
98
|
export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
|
|
96
99
|
export { checkPageFilename } from './doctor/page-filename.js';
|
|
@@ -111,7 +114,7 @@ export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, }
|
|
|
111
114
|
* 실행할 검사 이름. 지정 없음(undefined) = 31개 모두.
|
|
112
115
|
*/
|
|
113
116
|
/**
|
|
114
|
-
* doctor 정적 검사
|
|
117
|
+
* doctor 정적 검사 32종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
|
|
115
118
|
* 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
|
|
116
119
|
* 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
|
|
117
120
|
*/
|
|
@@ -125,6 +128,7 @@ export const ALL_RULES = [
|
|
|
125
128
|
'no-auto-import',
|
|
126
129
|
'schema-filename',
|
|
127
130
|
'agents-doc-index',
|
|
131
|
+
'agents-docs-stale',
|
|
128
132
|
'column-casing',
|
|
129
133
|
'model-filename',
|
|
130
134
|
'page-filename',
|
|
@@ -164,6 +168,7 @@ export const RULE_SUMMARIES = {
|
|
|
164
168
|
'no-auto-import': '자동 import',
|
|
165
169
|
'schema-filename': '스키마 파일명',
|
|
166
170
|
'agents-doc-index': 'AGENTS 색인',
|
|
171
|
+
'agents-docs-stale': 'AGENTS 문서 스테일',
|
|
167
172
|
'column-casing': '컬럼 casing',
|
|
168
173
|
'model-filename': '모델 파일명',
|
|
169
174
|
'page-filename': '페이지 파일명',
|
|
@@ -197,6 +202,7 @@ const CHECKERS = {
|
|
|
197
202
|
'no-auto-import': checkNoAutoImport,
|
|
198
203
|
'schema-filename': checkSchemaFilename,
|
|
199
204
|
'agents-doc-index': checkAgentsDocIndex,
|
|
205
|
+
'agents-docs-stale': checkAgentsDocsStale,
|
|
200
206
|
'column-casing': checkColumnCasing,
|
|
201
207
|
'model-filename': checkModelFilename,
|
|
202
208
|
'page-filename': checkPageFilename,
|
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";
|
|
@@ -100,5 +101,11 @@ export declare function readPortFlag(argv: readonly string[]): number | undefine
|
|
|
100
101
|
export declare function parseServeArgs(argv: readonly string[]): ServeCommandOptions;
|
|
101
102
|
/** `gaon dev` argv → DevCommandOptions. 포트 검증은 fail-loud(결정 240). */
|
|
102
103
|
export declare function parseDevArgs(argv: readonly string[]): DevCommandOptions;
|
|
104
|
+
/**
|
|
105
|
+
* 결정 448: 명령 공통 `--help`/`-h` 판정. `gaon test -- <인자>` 의 `--` 뒤는
|
|
106
|
+
* vitest 전달분이라 제외한다(패스스루 계약 유지 — `gaon test -- --help` 는
|
|
107
|
+
* vitest 의 도움말이지 gaon 의 도움말이 아니다).
|
|
108
|
+
*/
|
|
109
|
+
export declare function helpRequested(argv: readonly string[]): boolean;
|
|
103
110
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
104
111
|
export declare function runCli(argv: readonly string[], opts?: RunOptions): void;
|
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)",
|
|
@@ -141,7 +145,7 @@ function renderHelp(version = VERSION) {
|
|
|
141
145
|
" gaon db seed domain/seed.ts 실행 (M8)",
|
|
142
146
|
" gaon --json 같은 정보를 JSON 으로 출력",
|
|
143
147
|
" gaon --version 버전 출력",
|
|
144
|
-
" gaon --help 이 도움말",
|
|
148
|
+
" gaon --help 이 도움말 (어느 명령 뒤에 붙여도 부팅 없이 이 도움말 · 결정 448)",
|
|
145
149
|
"",
|
|
146
150
|
" 문서: " + HOMEPAGE,
|
|
147
151
|
"",
|
|
@@ -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(정본 32종)를 단일 출처로 쓴다 — 과거
|
|
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
|
+
// 32종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
|
|
177
181
|
if (unknown.length > 0) {
|
|
178
182
|
process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
|
|
179
183
|
` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
|
|
@@ -360,6 +364,19 @@ export function parseDevArgs(argv) {
|
|
|
360
364
|
host,
|
|
361
365
|
};
|
|
362
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* 결정 448: 명령 공통 `--help`/`-h` 판정. `gaon test -- <인자>` 의 `--` 뒤는
|
|
369
|
+
* vitest 전달분이라 제외한다(패스스루 계약 유지 — `gaon test -- --help` 는
|
|
370
|
+
* vitest 의 도움말이지 gaon 의 도움말이 아니다).
|
|
371
|
+
*/
|
|
372
|
+
export function helpRequested(argv) {
|
|
373
|
+
const sepIdx = argv.indexOf("--");
|
|
374
|
+
const before = (flag) => {
|
|
375
|
+
const i = argv.indexOf(flag);
|
|
376
|
+
return i >= 0 && (sepIdx < 0 || i < sepIdx);
|
|
377
|
+
};
|
|
378
|
+
return before("--help") || before("-h");
|
|
379
|
+
}
|
|
363
380
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
364
381
|
export function runCli(argv, opts = {}) {
|
|
365
382
|
const version = opts.version ?? VERSION;
|
|
@@ -370,6 +387,13 @@ export function runCli(argv, opts = {}) {
|
|
|
370
387
|
// 명령이 추가될 때마다 또 빠지므로 여기서 공통화한다. loadDotEnv 는 이미
|
|
371
388
|
// 설정된 env 를 덮지 않고 파일이 없으면 조용히 지나가 멱등하다(재호출 안전).
|
|
372
389
|
loadDotEnv();
|
|
390
|
+
// 결정 448: `--help`/`-h` 는 **어느 명령에서든** 부팅·실행 대신 도움말을 낸다 —
|
|
391
|
+
// 종전엔 말미(명령 미매치 경로)에서만 처리해 `gaon serve --help`·`gaon hub --help`
|
|
392
|
+
// 가 실제 부팅을 시도했다(redis/NATS 미기동이면 접속 실패로 종료 · 실사용 보고).
|
|
393
|
+
if (helpRequested(argv)) {
|
|
394
|
+
process.stdout.write(renderHelp(version) + "\n");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
373
397
|
// `gaon dev` — 통합 개발 오케스트레이션(M9-C · v0.15 §13.5). Docker Compose
|
|
374
398
|
// 자동 기동 + .gaon 재생성 + serve·work·hub 자식(운영 3종 all-in-one · 결정 211)
|
|
375
399
|
// + tsc/vue-tsc watch + 서버 재시작 워처. SIGINT/SIGTERM 시 순서대로 정리
|
|
@@ -473,7 +497,7 @@ export function runCli(argv, opts = {}) {
|
|
|
473
497
|
});
|
|
474
498
|
return;
|
|
475
499
|
}
|
|
476
|
-
// `gaon doctor` — 정적 검사(M9-E ·
|
|
500
|
+
// `gaon doctor` — 정적 검사(M9-E · 32 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
|
|
477
501
|
// 선택 실행, --json 은 자동화 파싱용.
|
|
478
502
|
// exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
|
|
479
503
|
if (argv[0] === "doctor") {
|
|
@@ -630,6 +654,15 @@ export function runCli(argv, opts = {}) {
|
|
|
630
654
|
process.exitCode = code;
|
|
631
655
|
return;
|
|
632
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
|
+
}
|
|
633
666
|
const known = ["controller", "model", "page", "job", "app", "channel"];
|
|
634
667
|
const type = argv[1];
|
|
635
668
|
if (type && known.includes(type)) {
|
|
@@ -681,8 +714,8 @@ export function runCli(argv, opts = {}) {
|
|
|
681
714
|
return;
|
|
682
715
|
}
|
|
683
716
|
process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
|
|
684
|
-
` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app | channel\n` +
|
|
685
|
-
` → 옵션: --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`);
|
|
686
719
|
process.exitCode = 1;
|
|
687
720
|
return;
|
|
688
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
|
+
}
|
package/dist/scaffold/channel.js
CHANGED
|
@@ -39,6 +39,9 @@ export function channelScaffold(rawName, app, instance) {
|
|
|
39
39
|
` instance: true,`,
|
|
40
40
|
` // 인스턴스 단위 입장 판정 — ctx.instance 가 인스턴스 키(예: 매치 id·게시물 id)다.`,
|
|
41
41
|
` // 참가자 검증이 필요하면 DB 로 확인한다(예: 매치 참가자 테이블 조회).`,
|
|
42
|
+
` // 함정(결정 447): 숫자 키를 DB id 로 파싱해 검증한다면 **정규형 비교**까지 —`,
|
|
43
|
+
` // BigInt("042")·BigInt("0x2a") 도 42 라서, String(id) !== ctx.instance 면 거부해야`,
|
|
44
|
+
` // 같은 방의 유령 인스턴스(로스터에 안 보이는 우회 접속)를 막는다(agents/realtime.md §2.7).`,
|
|
42
45
|
` authorize(ctx) {`,
|
|
43
46
|
` return ctx.user != null // 로그인 사용자만 — 공개 관전형이면 이 훅을 지운다`,
|
|
44
47
|
` },`,
|
|
@@ -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` 검사 32종
|
|
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,8 @@ 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)
|
|
149
150
|
|
|
150
151
|
## 3. 로직 배치 One Way 판단표
|
|
151
152
|
|
|
@@ -206,7 +207,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
206
207
|
```bash
|
|
207
208
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
|
|
208
209
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
209
|
-
gaon doctor # 정적 검사
|
|
210
|
+
gaon doctor # 정적 검사 32종 (§2.2)
|
|
210
211
|
```
|
|
211
212
|
|
|
212
213
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -218,6 +219,7 @@ gaon doctor # 정적 검사 31종 (§2.2)
|
|
|
218
219
|
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
|
|
219
220
|
| `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
221
|
| `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) |
|
|
222
|
+
| `gaon g agents-docs` | **gaonjs 업그레이드 후 이 문서들(`AGENTS.md`·`agents/*.md`)을 설치된 정본 템플릿으로 재동기** — 멱등(이미 최신 = no-op) · `--check` = 쓰지 않고 스테일 목록만(스테일 있으면 exit 1 · CI 드리프트 감지) · `--json`. doctor `agents-docs-stale`(§2.2) 이 스테일을 경고로 표면화한다 (결정 449) |
|
|
221
223
|
| `gaon gen` / `build` | `gen` = `.gaon` 타입 브리지 + api() 런타임 매니페스트만 재생성(서버·검사 없이) · `build` = 멀티 앱 프론트 프로덕션 빌드(`gaon gen` + `apps/*` 순회 · 앱별 `dist/<앱>`·base=`/<앱>/`) · 결정 127·146 |
|
|
222
224
|
| `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
|
|
223
225
|
| `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 # 정적 검사 32종 (상세 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 실행
|
|
@@ -209,10 +209,19 @@ import { MatchPlayer } from '../../../domain/models/MatchPlayer.js'
|
|
|
209
209
|
export default channel({
|
|
210
210
|
instance: true, // ← 파라미터화 선언
|
|
211
211
|
async authorize(ctx) {
|
|
212
|
-
// ctx.instance = 매치 id — 인스턴스 단위 입장 판정(참가자만)
|
|
212
|
+
// ctx.instance = 매치 id — 인스턴스 단위 입장 판정(참가자만).
|
|
213
|
+
// 결정 447: 숫자 키는 **정규형 비교까지** — BigInt("042")·BigInt("0x2a") 도 42 라서
|
|
214
|
+
// 파싱만 하면 같은 매치의 "유령 인스턴스"(격리는 따로, DB 는 같은 방)가 열린다.
|
|
213
215
|
const u = ctx.user as { id: bigint } | null
|
|
214
216
|
if (!u) return false
|
|
215
|
-
|
|
217
|
+
let matchId: bigint
|
|
218
|
+
try {
|
|
219
|
+
matchId = BigInt(ctx.instance)
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
if (String(matchId) !== ctx.instance) return false // 비정규 표기("042"·"0x2a"·공백) 거부
|
|
224
|
+
return await MatchPlayer.where({ matchId, userId: u.id }).exists()
|
|
216
225
|
},
|
|
217
226
|
onMessage(ctx, data) {
|
|
218
227
|
// ctx.broadcast 는 자기 인스턴스(match:<id>)로 자동 스코프 — 다른 매치는 못 받는다
|
|
@@ -284,6 +293,13 @@ const draft = ref('')
|
|
|
284
293
|
- **인스턴스 키는 임의 문자열이다**(128자 이하) — 매치 id·게시물 id 같은
|
|
285
294
|
식별자를 그대로 쓴다. subject/KV 에는 identity 전체가 base64url 로 인코딩돼
|
|
286
295
|
들어가므로 특수문자 주입 걱정이 없다.
|
|
296
|
+
- **숫자 키를 DB id 로 파싱해 검증한다면 정규형 비교까지(결정 447).** 격리는
|
|
297
|
+
인스턴스 **문자열** 단위인데 `BigInt()`/`Number()` 파싱은 `"042"`·`"0x2a"`·
|
|
298
|
+
`" 42"` 를 전부 42 로 받으므로, 파싱 성공만 검사하면 인증 사용자가 같은 방의
|
|
299
|
+
**유령 인스턴스**를 열 수 있다 — 로스터·라이브에는 안 보이면서 `onMessage` 가
|
|
300
|
+
같은 DB row(방 42)에 기록을 남긴다. 위 match 예시처럼 파싱 후
|
|
301
|
+
`String(id) !== ctx.instance → false` 로 정규형만 통과시킨다(uuid·slug 처럼
|
|
302
|
+
문자열 그대로 조회하는 키는 해당 없음).
|
|
287
303
|
- **`ctx.instance` 는 타입으로 갈린다** — `instance: true` 채널의 훅에서는
|
|
288
304
|
`string`(항상 존재), 정적 채널에서는 `undefined`(string 으로 쓰면 컴파일 에러).
|
|
289
305
|
- **`presenceOf(member)` 는 역방향 조회다** — 멤버(`user:<id>`·`conn:<uuid>`)가
|
|
@@ -353,7 +369,11 @@ import { instancesOf } from 'gaonjs/async'
|
|
|
353
369
|
export default controller({
|
|
354
370
|
async show() {
|
|
355
371
|
// 영속 방이면 DB(Room.all())가, 일시적 방이면 instancesOf 가 첫 목록이다.
|
|
356
|
-
|
|
372
|
+
// 결정 446: 인원 수·메타가 필요하면 옵션으로 동봉한다 — 방마다 presenceList
|
|
373
|
+
// 를 따로 부르는 N+1 을 만들지 말 것(counts 는 프레즌스 키 단일 스캔).
|
|
374
|
+
const rooms = await instancesOf('match', { counts: true })
|
|
375
|
+
// rooms = [{ instance: '42', members: 3 }, …] · { meta: true, counts: true } 조합도 된다
|
|
376
|
+
return this.render('Lobby', { rooms })
|
|
357
377
|
},
|
|
358
378
|
})
|
|
359
379
|
```
|
|
@@ -368,11 +388,11 @@ export default channel({})
|
|
|
368
388
|
`room-closed` 를 반영한다 — 리스너의 `broadcast('lobby', …)` 단일 발행이 전
|
|
369
389
|
서버 로비 구독자에게 팬아웃된다(§2.5).
|
|
370
390
|
|
|
371
|
-
### 2.9 룸 프리미티브 — 정원·kick
|
|
391
|
+
### 2.9 룸 프리미티브 — 정원·kick·메타·입장순·멤버 이탈 (결정 444·445)
|
|
372
392
|
|
|
373
|
-
인스턴스 채널(§2.7) 위의 "방 운영" 표면
|
|
374
|
-
(모더레이터 강퇴·방 인원 제한·방
|
|
375
|
-
|
|
393
|
+
인스턴스 채널(§2.7) 위의 "방 운영" 표면 5종. 전부 도메인 중립이다 — 채팅
|
|
394
|
+
(모더레이터 강퇴·방 인원 제한·방 규칙·방장 승계)과 게임(안티치트 축출·매치
|
|
395
|
+
정원·매치 설정·호스트 승계)이 같은 프리미티브를 쓴다.
|
|
376
396
|
|
|
377
397
|
**① 정원 `maxMembers` — 허브 원자 판정.** 정의에 선언하면 초과 입장이
|
|
378
398
|
`4409` 로 거부된다(허브가 전역 로스터 단일 권위로 **동시 입장 race 없이**
|
|
@@ -424,7 +444,14 @@ await kick('room', `user:${userId}`, { instance: roomId, reason })
|
|
|
424
444
|
async authorize(ctx) {
|
|
425
445
|
const u = ctx.user as { id: bigint } | null
|
|
426
446
|
if (!u) return false
|
|
427
|
-
|
|
447
|
+
let roomId: bigint
|
|
448
|
+
try {
|
|
449
|
+
roomId = BigInt(ctx.instance)
|
|
450
|
+
} catch {
|
|
451
|
+
return false
|
|
452
|
+
}
|
|
453
|
+
if (String(roomId) !== ctx.instance) return false // 정규형만(§2.7 유령 인스턴스 · 결정 447)
|
|
454
|
+
return !(await RoomBan.where({ roomId, userId: u.id }).exists())
|
|
428
455
|
}
|
|
429
456
|
```
|
|
430
457
|
|
|
@@ -455,25 +482,60 @@ const rooms = await instancesOf('match', { meta: true }) // 로
|
|
|
455
482
|
`presenceList`/`ctx.presence()`/클라 `members` 가 **입장순 정렬을 보장**
|
|
456
483
|
하므로 "가장 먼저 들어온 남은 멤버" = `[0]` 이 전 서버 어디서나 결정적이다.
|
|
457
484
|
|
|
458
|
-
|
|
459
|
-
(
|
|
485
|
+
**⑤ `MemberLeft` — 멤버 이탈의 race-free 앵커(결정 445).** 인스턴스 채널에서
|
|
486
|
+
멤버가 **완전히 떠나면**(마지막 연결 소멸 — 멀티탭 중간 탭 닫힘은 침묵 · 서버
|
|
487
|
+
프로세스 死 포함) 허브가 **로스터에서 제거한 뒤** 발행한다. 페이로드에 "누가
|
|
488
|
+
나갔는가 + **제거가 반영된** 잔존 로스터(입장순)"가 실리므로, "떠난 뒤의
|
|
489
|
+
로스터로 한 곳에서 결정" 하는 로직의 정본 앵커다. `InstanceOpened/Closed` 와
|
|
490
|
+
같은 전달(워커 하나만 처리 · at-least-once — 핸들러 멱등):
|
|
460
491
|
|
|
461
492
|
```ts
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
493
|
+
export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
|
|
494
|
+
// roster = 제거 반영 후 잔존 멤버 [{ id, joinSeq? }] · joinSeq 오름차순.
|
|
495
|
+
// 빈 배열 = 마지막 이탈(InstanceClosed 도 발행되지만 컨슈머 축이 달라
|
|
496
|
+
// 상호 순서는 보장 없음 — 빈 로스터 자체를 마지막 이탈 신호로 쓰라).
|
|
497
|
+
})
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
- **채널 `onLeave` 안 `ctx.presence()` 로 이탈 후 로스터를 읽지 말 것** — leave
|
|
501
|
+
는 훅 앞에서 **발신**될 뿐 KV 반영은 허브 비동기라, 떠나는 멤버 본인이 아직
|
|
502
|
+
로스터에 보일 수 있다(로컬에선 대개 허브가 이겨 테스트는 통과하고, 부하·순단
|
|
503
|
+
에서 뒤집히는 전형적 race — rooms 샘플 실측). 게다가 웹서버가 통째로 죽으면
|
|
504
|
+
`onLeave` 는 아예 돌지 않는다. 둘 다 `MemberLeft` 가 닫는다.
|
|
505
|
+
- **발화는 이탈만·인스턴스 채널만이다** — 입장 후 로직은 `onJoin`(연결)·
|
|
506
|
+
`InstanceOpened`(첫 점유)가 담당하고, 정적 채널은 전역 churn 고빈도 + 소멸
|
|
507
|
+
앵커 부재로 발화하지 않는다(결정 442 스코프 동형).
|
|
508
|
+
|
|
509
|
+
**방장(호스트) 승계는 앱 도메인 패턴이다** — 프레임웍은 결정적 순서(joinSeq)와
|
|
510
|
+
race-free 앵커(MemberLeft)만 준다("owner" 프리미티브 없음):
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
// domain/listeners/onRoomMemberLeft.ts — 채팅 방장 / 게임 호스트 공통 골격.
|
|
514
|
+
// 워커 하나만 처리하므로 멀티서버 동시 이탈에도 승계가 한 곳에서 결정된다.
|
|
515
|
+
import { on, MemberLeft, broadcast } from 'gaonjs/async'
|
|
516
|
+
import { Room } from '../models/Room.js'
|
|
517
|
+
|
|
518
|
+
export default on(MemberLeft, async ({ channel, instance, member, roster }) => {
|
|
519
|
+
if (channel !== 'room') return
|
|
520
|
+
if (roster.length === 0) return // 마지막 이탈 — 닫힘은 InstanceClosed 몫
|
|
521
|
+
const room = await Room.where('key', '=', instance).first()
|
|
522
|
+
if (!room) return
|
|
523
|
+
if (roster.some((m) => m.id === `user:${String(room.ownerId)}`)) return // 방장 건재
|
|
524
|
+
const next = roster[0] // 가장 먼저 들어온 남은 멤버(입장순 보장)
|
|
525
|
+
if (!next.id.startsWith('user:')) return
|
|
526
|
+
// 조건부 갱신(멱등) — at-least-once 재전달·경합에서 두 번 승격되지 않는다.
|
|
527
|
+
const changed = await Room.where('id', '=', room.id)
|
|
528
|
+
.where('ownerId', '=', room.ownerId)
|
|
529
|
+
.updateAll({ ownerId: BigInt(next.id.slice('user:'.length)) })
|
|
530
|
+
if (changed > 0) broadcast('room', { type: 'owner-changed', owner: next.id }, { instance })
|
|
467
531
|
})
|
|
468
|
-
// 승계(owner 이탈 감지 시 — 도메인 액션·리스너 어디서든):
|
|
469
|
-
const roster = await presenceList('match', { instance })
|
|
470
|
-
const next = roster[0] // 가장 먼저 들어온 남은 멤버
|
|
471
|
-
if (next) {
|
|
472
|
-
await setInstanceMeta('match', { owner: next.id }, { instance })
|
|
473
|
-
broadcast('match', { type: 'owner-changed', owner: next.id }, { instance })
|
|
474
|
-
}
|
|
475
532
|
```
|
|
476
533
|
|
|
534
|
+
영속 방(위 예시)은 **DB 컬럼(rooms.ownerId)이 owner 의 정본**이다 — 메타
|
|
535
|
+
(`setInstanceMeta({ owner })`)는 닫힘 시 소멸하는 표시층이라, 방 row 가 남는
|
|
536
|
+
도메인이면 DB 로 두고 메타는 로비 표시 등에만 쓴다. 일시적 방(row 없는 매치)
|
|
537
|
+
이면 메타 owner 로 충분하다.
|
|
538
|
+
|
|
477
539
|
### 3. 프레즌스
|
|
478
540
|
|
|
479
541
|
`ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
|
|
@@ -757,9 +819,16 @@ export default channel({
|
|
|
757
819
|
- **방 생성/삭제를 허브·채널 훅에서 직접 DB 로 밀지 말 것** — 훅(onJoin/onLeave)은
|
|
758
820
|
연결 단위라 첫/마지막 판정이 서버 로컬에 갇히고(멀티서버에서 틀림), 허브는
|
|
759
821
|
도메인을 모른다. 전역 첫/마지막은 생명주기 이벤트(§2.8)가 정답 경로다.
|
|
760
|
-
훅의 단위 경계 3
|
|
761
|
-
`ctx.connectionId` 로 구분) / presence 델타 = 멤버(첫·마지막 연결
|
|
762
|
-
|
|
822
|
+
훅의 단위 경계 3층 + 멤버층 서버 이벤트: **onJoin/onLeave = 연결(멀티탭이면
|
|
823
|
+
연결마다 · `ctx.connectionId` 로 구분) / presence 델타 = 멤버(첫·마지막 연결 ·
|
|
824
|
+
클라 통지) / `MemberLeft` = 멤버 이탈의 서버측 앵커(§2.9 ⑤ · 결정 445 ·
|
|
825
|
+
로스터 반영 후 · 워커 1회 처리) / InstanceOpened·Closed = 인스턴스(전역 첫
|
|
826
|
+
점유·점유 0)**.
|
|
827
|
+
- **이탈 후 로스터 판정을 `onLeave` + `ctx.presence()` 로 조립하지 말 것(결정 445)** —
|
|
828
|
+
leave 는 훅 앞에서 발신될 뿐 KV 반영은 허브 비동기라 이탈자 본인이 로스터에
|
|
829
|
+
잔존할 수 있고(로컬에선 대개 통과하다 부하·순단에서 뒤집히는 race), 서버
|
|
830
|
+
프로세스 死 경로에서는 훅이 아예 안 돈다. 방장 승계처럼 "떠난 뒤의 로스터로
|
|
831
|
+
결정" 하는 로직은 `on(MemberLeft, …)` 리스너가 정본이다(§2.9 ⑤).
|
|
763
832
|
- **`authorize` 안 `presenceList` 로 정원을 검사하지 말 것** — 웹서버 로컬 판정이라
|
|
764
833
|
동시 입장 race 에서 초과 입장이 뚫린다. 정원은 **`maxMembers` 선언**(§2.9 ·
|
|
765
834
|
허브 원자 판정 · 4409)이 정본이다.
|
|
@@ -803,6 +872,10 @@ export default channel({
|
|
|
803
872
|
| 결정 440 | 파라미터화(인스턴스) 채널(§2.7) — `instance: true` 선언 · identity = `<이름>:<인스턴스>`(Phoenix topic 모델) · 전송 subject·프레즌스·인가·broadcast 네 축 identity 격리 · 역방향 인덱스(`member.<멤버>.<채널>`)로 `presenceOf(member)` 한 번 스캔 · `presenceList(name, {instance})` 서버 개시 로스터 · 선언·연결 부정합 = `4400` fail-loud(재연결 없음) · doctor `channel-instance-authorize` 경고 · 정적 채널은 identity=이름 그대로(와이어·KV 무변경) |
|
|
804
873
|
| 결정 442 | 소켓 앱 DX 표면 완성(§2.8) — 인스턴스 생명주기 이벤트 `InstanceOpened`/`InstanceClosed`(허브 발행 · 활성 인스턴스 인덱스 `chan.<이름>.<인스턴스>` 가 dedup 앵커 · 리스너 durable 공유로 워커 하나만 처리) · `instancesOf(name)` 채널→활성 인스턴스 목록(로비 첫 렌더) · 로비 = 일반 채널 + `broadcast` 패턴(전용 API 신설 없음) · `gaon g channel <이름> [--instance]` 스캐폴드(정의 + 컴포저블) |
|
|
805
874
|
| 결정 444 | 룸 프리미티브 Wave 2(§2.9) — 정원 `maxMembers`(허브 원자 판정 joinAck · 초과 `4409` 종단 · 멤버 수 기준 멀티탭 통과) · `kick(name, member, {instance, reason})`(userSubject 제어 봉투 · `4403` 종단 + `onKicked` · 반환 = present 수 · 마지막 멤버면 closed 자동) · ban = 3단 문서 패턴(DB + authorize + kick · 프리미티브 없음) · 인스턴스 메타(`instanceMeta` 훅 = open 앵커 1회 기록 · `setInstanceMeta` LWW 갱신 · 닫힘 자동 삭제 · 4096B · 정적 채널 미지원) · `joinSeq` 입장 순번(허브 부여 · `presenceList`/`members` 입장순 정렬 보장 · 방장 승계 패턴 기반) · `ctx.connectionId` |
|
|
875
|
+
| 결정 445 | `MemberLeft` 멤버 이탈 이벤트(§2.9 ⑤) — 허브가 로스터 **제거 후** 발행(제거 반영 로스터 동봉 · 입장순) · 마지막 연결에서만(멀티탭 침묵) · graceful·synced 대사·서버 死(cleanupServer)·failover 회수 전 경로 단일 수렴점(delMember) · 인스턴스 채널 한정·이탈만(444(F) 볼륨 우려 수용) · 워커 1회 처리(442 전달 동형) · 방장 승계 정본 앵커("onLeave + ctx.presence() 재조회" 패턴은 leave 발신≠반영 race + 서버 死 미커버로 반정본 명문화 — rooms 샘플 실측) |
|
|
876
|
+
| 결정 446 | `instancesOf(name, { counts: true })`(§2.8) — 인스턴스별 **멤버 수** 동봉(프레즌스 키 단일 스캔 · presenceStats 동형) · `{ meta: true, counts: true }` 조합 지원 · 로비 인원 수 per-인스턴스 `presenceList` N+1 제거 · 기존 시그니처 불변 |
|
|
877
|
+
| 결정 447 | 인스턴스 키 정규형 비교 정본화(§2.7·§2.9 ban) — 숫자 키를 `BigInt()`/`Number()` 로 파싱해 검증하는 authorize 는 `String(id) !== ctx.instance → false` 정규형 가드까지(비정규 표기 "042"·"0x2a" 가 로스터에 안 보이는 유령 인스턴스로 같은 DB row 에 기록을 남기는 우회 차단) · 프레임웍 자동 정규화 기각(키는 앱 정의 불투명 문자열 — uuid·slug 는 파싱 무관) |
|
|
878
|
+
| 결정 448 | CLI 명령 공통 `--help`(`packages/cli`) — `gaon serve --help`·`gaon hub --help` 가 부팅을 시도하던 것을 디스패치 전 공통 처리로 봉합 · `gaon test -- <인자>` 의 `--` 뒤는 패스스루 보존 |
|
|
806
879
|
|
|
807
880
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
808
881
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
33
33
|
"typescript": "^5.9.0",
|
|
34
34
|
"vite": "^7.0.0",
|
|
35
|
-
"@gaonjs/async": "0.
|
|
36
|
-
"@gaonjs/config": "0.25.
|
|
37
|
-
"@gaonjs/
|
|
35
|
+
"@gaonjs/async": "0.22.0",
|
|
36
|
+
"@gaonjs/config": "0.25.6",
|
|
37
|
+
"@gaonjs/mail": "0.5.1",
|
|
38
38
|
"@gaonjs/i18n": "0.3.1",
|
|
39
|
+
"@gaonjs/data": "0.25.3",
|
|
39
40
|
"@gaonjs/core": "0.3.0",
|
|
40
|
-
"@gaonjs/
|
|
41
|
-
"@gaonjs/web": "0.31.1"
|
|
41
|
+
"@gaonjs/web": "0.31.2"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""
|