@gaonjs/cli 0.18.0 → 0.23.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/csrf-wiring.d.ts +5 -0
- package/dist/doctor/csrf-wiring.js +72 -0
- package/dist/doctor/internal-anchor.d.ts +6 -0
- package/dist/doctor/internal-anchor.js +122 -0
- package/dist/doctor/method-override.d.ts +5 -0
- package/dist/doctor/method-override.js +75 -0
- package/dist/doctor/route-registration.d.ts +5 -0
- package/dist/doctor/route-registration.js +77 -0
- package/dist/doctor/static-collision.d.ts +5 -0
- package/dist/doctor/static-collision.js +84 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +2 -4
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +24 -2
- package/dist/generate.d.ts +8 -0
- package/dist/generate.js +51 -7
- package/dist/index.d.ts +6 -2
- package/dist/index.js +33 -23
- package/dist/serve.d.ts +10 -0
- package/dist/serve.js +97 -2
- package/dist/templates/auth/Login.vue.tpl +2 -2
- package/dist/templates/auth/Signup.vue.tpl +2 -2
- package/dist/templates/auth/app.ts.tpl +29 -0
- package/dist/templates/auth/server.ts.tpl +14 -0
- package/dist/templates/project/.dockerignore.tpl +12 -0
- package/dist/templates/project/AGENTS.md.tpl +18 -7
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/Dockerfile.tpl +30 -0
- package/dist/templates/project/agents/async.md.tpl +4 -0
- package/dist/templates/project/agents/data.md.tpl +37 -15
- package/dist/templates/project/agents/frontend.md.tpl +33 -1
- package/dist/templates/project/agents/realtime.md.tpl +24 -17
- package/dist/templates/project/agents/security.md.tpl +18 -2
- package/dist/templates/project/agents/web.md.tpl +47 -0
- package/dist/templates/project/apps/web/app.config.ts.tpl +14 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +6 -2
- package/dist/templates/project/apps/web/static/robots.txt.tpl +4 -0
- package/dist/templates/project/compose.prod.yaml.tpl +98 -0
- package/dist/templates/project/package.json.tpl +1 -0
- package/dist/work.js +2 -0
- package/package.json +7 -7
package/dist/generate.js
CHANGED
|
@@ -69,6 +69,38 @@ export function patchRoutes(existing) {
|
|
|
69
69
|
"\n r.resource('registration') // 회원가입 (gaon g auth)";
|
|
70
70
|
return existing.slice(0, insertAt) + inject + existing.slice(insertAt);
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* 기존 app.config.ts 에 auth 배선을 끼워 넣는다 (결정 93 · gaon new 기본 web 앱은
|
|
74
|
+
* 세션만 배선돼 있어 g auth 는 auth 만 추가하면 완성된다). 반환:
|
|
75
|
+
* · auth 가 이미 있으면 null (그대로 둔다),
|
|
76
|
+
* · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
|
|
77
|
+
* 성공 시 loadUser import 도 함께 보장한다.
|
|
78
|
+
*/
|
|
79
|
+
export function patchAppConfigAddAuth(existing) {
|
|
80
|
+
if (/\bauth\s*:/.test(existing))
|
|
81
|
+
return null;
|
|
82
|
+
const m = existing.match(/defineAppConfig\(\s*\{/);
|
|
83
|
+
if (!m || m.index === undefined)
|
|
84
|
+
return null;
|
|
85
|
+
const insertAt = m.index + m[0].length;
|
|
86
|
+
let out = existing.slice(0, insertAt) +
|
|
87
|
+
"\n // 결정 59: 세션 userId → 사용자 로드 배선(g auth). 이게 있어야 로그인 후" +
|
|
88
|
+
"\n // this.currentUser / this.requireAuth() 가 실제 사용자를 받는다." +
|
|
89
|
+
"\n auth: { loadUser, loginRedirect: '/session/new' }," +
|
|
90
|
+
existing.slice(insertAt);
|
|
91
|
+
// loadUser import 보장 — gaonjs/config import 바로 뒤에 넣는다.
|
|
92
|
+
if (!/from\s+['"]\.\/auth\.js['"]/.test(out)) {
|
|
93
|
+
const im = out.match(/import\s+\{[^}]*\}\s+from\s+['"]gaonjs\/config['"][^\n]*\n/);
|
|
94
|
+
if (im && im.index !== undefined) {
|
|
95
|
+
const at = im.index + im[0].length;
|
|
96
|
+
out = out.slice(0, at) + "import { loadUser } from './auth.js'\n" + out.slice(at);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
out = "import { loadUser } from './auth.js'\n" + out;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
72
104
|
/** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
|
|
73
105
|
export function writeAuthScaffold(cwd, opts = {}) {
|
|
74
106
|
const root = resolve(cwd);
|
|
@@ -87,19 +119,31 @@ export function writeAuthScaffold(cwd, opts = {}) {
|
|
|
87
119
|
for (const file of authScaffoldFiles(opts)) {
|
|
88
120
|
const abs = join(root, file.path);
|
|
89
121
|
if (existsSync(abs)) {
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
//
|
|
122
|
+
// 결정 93: gaon new 기본 web 앱은 app.config.ts 에 세션만 배선돼 있다.
|
|
123
|
+
// g auth 는 이를 skip 하지 않고 **auth 배선을 패치로 추가**해 완성한다 —
|
|
124
|
+
// 결정 59: auth 배선이 없으면 로그인 후에도 currentUser 가 영구 null 이다.
|
|
93
125
|
if (file.path === `apps/${app}/app.config.ts`) {
|
|
94
126
|
const existing = readFileSync(abs, 'utf8');
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
const patchedConfig = patchAppConfigAddAuth(existing);
|
|
128
|
+
if (patchedConfig) {
|
|
129
|
+
writeFileSync(abs, patchedConfig, 'utf8');
|
|
130
|
+
patched.push(file.path);
|
|
131
|
+
}
|
|
132
|
+
else if (!/\bauth\s*:/.test(existing)) {
|
|
133
|
+
// 패치 불가(비관례 config) — skip + 수리 안내로 폴백.
|
|
134
|
+
skipped.push(file.path);
|
|
135
|
+
warnings.push(`apps/${app}/app.config.ts 가 이미 있어 인증 배선을 자동 추가하지 못했습니다.\n` +
|
|
136
|
+
`→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
|
|
99
137
|
` auth: { loadUser, loginRedirect: '/session/new' } // import { loadUser } from './auth.js'\n` +
|
|
100
138
|
`→ 이 배선이 없으면 로그인해도 this.currentUser/requireAuth() 가 사용자를 받지 못합니다.`);
|
|
101
139
|
}
|
|
140
|
+
else {
|
|
141
|
+
// 이미 auth 배선됨 — 그대로 둔다.
|
|
142
|
+
skipped.push(file.path);
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
102
145
|
}
|
|
146
|
+
skipped.push(file.path);
|
|
103
147
|
continue;
|
|
104
148
|
}
|
|
105
149
|
mkdirSync(dirname(abs), { recursive: true });
|
package/dist/index.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { loadDomain, type LoadedDomain } from "./domain.js";
|
|
|
23
23
|
export interface RoadmapReport {
|
|
24
24
|
readonly name: "gaon";
|
|
25
25
|
readonly version: string;
|
|
26
|
-
readonly stage: "
|
|
26
|
+
readonly stage: "stable";
|
|
27
27
|
readonly homepage: string;
|
|
28
28
|
readonly docs: string;
|
|
29
29
|
readonly milestones: readonly {
|
|
@@ -34,7 +34,11 @@ export interface RoadmapReport {
|
|
|
34
34
|
}
|
|
35
35
|
/** `--json` 출력용 구조화 리포트. */
|
|
36
36
|
export declare function roadmapReport(version?: string): RoadmapReport;
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* 인자 없이 실행했을 때의 사람용 배너 — 정식 배포(v1.0) 자기 소개 + 사용법 안내.
|
|
39
|
+
* 결정 94(W3): M1 스텁 시절 "개발 초기·런타임 없음" 오정보를 제거한다 — AI 가
|
|
40
|
+
* 사용 불가로 오판하거나 소형 모델이 멈추는 것을 막는다.
|
|
41
|
+
*/
|
|
38
42
|
export declare function renderRoadmap(version?: string): string;
|
|
39
43
|
/** runCli 옵션. version 은 파사드가 주입하는 표시 버전(설치 패키지 버전). */
|
|
40
44
|
export interface RunOptions {
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gaonjs/cli — Gaon CLI 구현.
|
|
3
3
|
*
|
|
4
|
-
* 인자 없이 실행하면
|
|
5
|
-
*
|
|
4
|
+
* 인자 없이 실행하면 정식 배포(v1.0) 배너와 사용법을 출력한다. `gaon dev`
|
|
5
|
+
* 가 통합 개발 오케스트레이션을 담당한다 — Docker Compose
|
|
6
6
|
* 자동 기동 + .gaon 타입 브리지 재생성 + serve 자식 프로세스 + tsc/vue-tsc
|
|
7
7
|
* --watch + 소스 변경 시 서버 재시작(commands/dev.ts). 모든 명령은 `--json`
|
|
8
8
|
* 출력을 함께 제공한다 (CLAUDE.md §4).
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 표시 버전은 호출자(파사드)가 주입한다 — 사용자가 설치한 패키지
|
|
11
11
|
* (`gaonjs`) 버전을 그대로 보여주기 위함. 미주입 시 core 버전을 쓴다.
|
|
12
12
|
*/
|
|
13
|
-
import { MILESTONES, VERSION, HOMEPAGE } from "@gaonjs/core";
|
|
13
|
+
import { MILESTONES, VERSION, HOMEPAGE, loadDotEnv } from "@gaonjs/core";
|
|
14
14
|
import { runDevCommand } from "./commands/dev.js";
|
|
15
15
|
import { runCheckCommand } from "./commands/check.js";
|
|
16
16
|
import { runNewCommand } from "./commands/new.js";
|
|
@@ -54,41 +54,38 @@ export function roadmapReport(version = VERSION) {
|
|
|
54
54
|
return {
|
|
55
55
|
name: "gaon",
|
|
56
56
|
version,
|
|
57
|
-
stage: "
|
|
57
|
+
stage: "stable",
|
|
58
58
|
homepage: HOMEPAGE,
|
|
59
59
|
docs: HOMEPAGE,
|
|
60
60
|
milestones: MILESTONES.map((m) => ({ id: m.id, title: m.title, status: m.status })),
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* 인자 없이 실행했을 때의 사람용 배너 — 정식 배포(v1.0) 자기 소개 + 사용법 안내.
|
|
65
|
+
* 결정 94(W3): M1 스텁 시절 "개발 초기·런타임 없음" 오정보를 제거한다 — AI 가
|
|
66
|
+
* 사용 불가로 오판하거나 소형 모델이 멈추는 것을 막는다.
|
|
67
|
+
*/
|
|
64
68
|
export function renderRoadmap(version = VERSION) {
|
|
65
69
|
const lines = [];
|
|
66
70
|
lines.push("");
|
|
67
71
|
lines.push(` Gaon (가온) — AI가 개발을 가장 잘하는 Node.js 풀스택 웹 프레임웍`);
|
|
68
|
-
lines.push(` v${version} ·
|
|
72
|
+
lines.push(` v${version} · 정식 배포 · ${HOMEPAGE}`);
|
|
69
73
|
lines.push("");
|
|
70
|
-
lines.push("
|
|
71
|
-
lines.push("
|
|
74
|
+
lines.push(" 데이터·인증·실시간·비동기·메일/스토리지·CLI 배터리가 모두 동작합니다.");
|
|
75
|
+
lines.push(" 새 프로젝트를 만들고 개발 스택을 통합 기동하려면:");
|
|
72
76
|
lines.push("");
|
|
73
|
-
lines.push("
|
|
74
|
-
lines.push("
|
|
75
|
-
|
|
76
|
-
const mark = m.status === "in-progress" ? "▶" : "·";
|
|
77
|
-
const tag = m.status === "in-progress" ? " (진행 중)" : "";
|
|
78
|
-
lines.push(` ${mark} ${m.id} ${m.title}${tag}`);
|
|
79
|
-
}
|
|
77
|
+
lines.push(" gaon new <name> 새 프로젝트 스캐폴드");
|
|
78
|
+
lines.push(" gaon dev 개발 스택 통합 기동 (Docker · .gaon · serve · watch)");
|
|
79
|
+
lines.push(" gaon --help 전체 명령 목록");
|
|
80
80
|
lines.push("");
|
|
81
|
-
lines.push(
|
|
82
|
-
lines.push(" ──────────");
|
|
83
|
-
lines.push(` 문서 / 진행 상황: ${HOMEPAGE}`);
|
|
84
|
-
lines.push(` JSON 출력: gaon --json`);
|
|
81
|
+
lines.push(` 문서: ${HOMEPAGE} · JSON 출력: gaon --json`);
|
|
85
82
|
lines.push("");
|
|
86
83
|
return lines.join("\n");
|
|
87
84
|
}
|
|
88
85
|
function renderHelp(version = VERSION) {
|
|
89
86
|
return [
|
|
90
87
|
"",
|
|
91
|
-
" gaon — Gaon 프레임웍 CLI (v" + version + "
|
|
88
|
+
" gaon — Gaon 프레임웍 CLI (v" + version + " · 정식 배포)",
|
|
92
89
|
"",
|
|
93
90
|
" 사용법:",
|
|
94
91
|
" gaon 로드맵과 개발 상태를 출력",
|
|
@@ -100,10 +97,11 @@ function renderHelp(version = VERSION) {
|
|
|
100
97
|
" gaon dev --json 통합 콘솔을 JSON 라인으로 출력(자동화)",
|
|
101
98
|
" gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
|
|
102
99
|
" gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
|
|
100
|
+
" gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
|
|
103
101
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
104
102
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
105
103
|
" gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
|
|
106
|
-
" gaon doctor 정적 검사 (
|
|
104
|
+
" gaon doctor 정적 검사 (19 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커)",
|
|
107
105
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
108
106
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
109
107
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -148,6 +146,7 @@ export function parseDoctorChecks(argv) {
|
|
|
148
146
|
"migration-diff",
|
|
149
147
|
"shared-composable-purity",
|
|
150
148
|
"no-auto-import",
|
|
149
|
+
"csrf-wiring",
|
|
151
150
|
];
|
|
152
151
|
const isKnown = (s) => known.includes(s);
|
|
153
152
|
const out = [];
|
|
@@ -164,6 +163,13 @@ export function parseDoctorChecks(argv) {
|
|
|
164
163
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
165
164
|
export function runCli(argv, opts = {}) {
|
|
166
165
|
const version = opts.version ?? VERSION;
|
|
166
|
+
// 결정 92(W1): `.env` 를 **진입점에서 한 번** 로드해 모든 명령이 동일하게
|
|
167
|
+
// 받는다. 이전엔 serve·dev·console 만 개별 호출해 db·work·hub·jobs·doctor·
|
|
168
|
+
// check 가 `.env` 없이 실행됐고(특히 work·hub 는 조용히 안 뜨는 부류),
|
|
169
|
+
// 실사용에서 `set -a; . ./.env` 수동 우회가 필요했다. 명령별 나열은 새
|
|
170
|
+
// 명령이 추가될 때마다 또 빠지므로 여기서 공통화한다. loadDotEnv 는 이미
|
|
171
|
+
// 설정된 env 를 덮지 않고 파일이 없으면 조용히 지나가 멱등하다(재호출 안전).
|
|
172
|
+
loadDotEnv();
|
|
167
173
|
// `gaon dev` — 통합 개발 오케스트레이션(M9-C · v0.15 §13.5). Docker Compose
|
|
168
174
|
// 자동 기동 + .gaon 재생성 + serve 자식 + tsc/vue-tsc watch + 서버 재시작 워처.
|
|
169
175
|
// SIGINT/SIGTERM 시 순서대로 정리(serve → tsc → 워처 → Docker[--stop-docker 시]).
|
|
@@ -194,11 +200,15 @@ export function runCli(argv, opts = {}) {
|
|
|
194
200
|
if (argv[0] === "serve") {
|
|
195
201
|
const portIdx = argv.indexOf("--port");
|
|
196
202
|
const hostIdx = argv.indexOf("--host");
|
|
203
|
+
const workersIdx = argv.indexOf("--workers");
|
|
197
204
|
const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
|
|
198
205
|
const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
|
|
206
|
+
// --workers <n|auto>: node:cluster 다중화(결정 84). env WEB_CONCURRENCY 도 가능.
|
|
207
|
+
const workersRaw = workersIdx >= 0 ? argv[workersIdx + 1] : undefined;
|
|
208
|
+
const workers = workersRaw === "auto" ? "auto" : workersRaw !== undefined ? Number(workersRaw) : undefined;
|
|
199
209
|
// --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식
|
|
200
210
|
// serve 에 넘긴다(결정 69 · dev-only by construction).
|
|
201
|
-
void runServeCommand({ json: argv.includes("--json"), port, host, dev: argv.includes("--dev") }).catch((err) => {
|
|
211
|
+
void runServeCommand({ json: argv.includes("--json"), port, host, workers, dev: argv.includes("--dev") }).catch((err) => {
|
|
202
212
|
const msg = err instanceof Error ? err.message : String(err);
|
|
203
213
|
process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
|
|
204
214
|
process.exitCode = 1;
|
|
@@ -230,7 +240,7 @@ export function runCli(argv, opts = {}) {
|
|
|
230
240
|
});
|
|
231
241
|
return;
|
|
232
242
|
}
|
|
233
|
-
// `gaon doctor` — 정적 검사(M9-E ·
|
|
243
|
+
// `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
|
|
234
244
|
// 선택 실행, --json 은 자동화 파싱용.
|
|
235
245
|
// exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
|
|
236
246
|
if (argv[0] === "doctor") {
|
package/dist/serve.d.ts
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
|
|
3
|
+
* (availableParallelism). 0·음수·비수치는 1 로 떨어진다(안전 기본). 결정 84.
|
|
4
|
+
*/
|
|
5
|
+
export declare function resolveWorkerCount(workers: number | 'auto' | undefined, env?: NodeJS.ProcessEnv): number;
|
|
1
6
|
export interface ServeCommandOptions {
|
|
2
7
|
readonly cwd?: string;
|
|
3
8
|
readonly json?: boolean;
|
|
@@ -5,6 +10,11 @@ export interface ServeCommandOptions {
|
|
|
5
10
|
readonly port?: number;
|
|
6
11
|
/** 리슨 호스트. 우선순위: 옵션 > config.web.host > '0.0.0.0'. */
|
|
7
12
|
readonly host?: string;
|
|
13
|
+
/**
|
|
14
|
+
* 워커 수(node:cluster). 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수.
|
|
15
|
+
* 2 이상이면 프라이머리가 fork 해 다중화한다(결정 84).
|
|
16
|
+
*/
|
|
17
|
+
readonly workers?: number | 'auto';
|
|
8
18
|
/**
|
|
9
19
|
* dev 모드(gaon dev 자식). true 면 dev 전용 진단 라우트(/_gaon/health)를
|
|
10
20
|
* 등록한다. 운영 serve 는 이 플래그 없이 실행되어 진단 라우트가 노출되지
|
package/dist/serve.js
CHANGED
|
@@ -8,13 +8,32 @@
|
|
|
8
8
|
* env 를 먼저 로드한다 — .env 의 값이 gaon.config.ts 안의 env('KEY') 에
|
|
9
9
|
* 들어갈 수 있어야 하기 때문.
|
|
10
10
|
*
|
|
11
|
-
* 워커
|
|
12
|
-
* 1
|
|
11
|
+
* 워커 다중화(--workers · WEB_CONCURRENCY)는 node:cluster 로 처리한다(결정 84):
|
|
12
|
+
* 기본 1(컨테이너 기본). 2 이상이면 프라이머리가 N 워커를 fork 하고 OS 가
|
|
13
|
+
* 연결을 분산(cluster 라운드로빈)한다. 워커가 예기치 않게 죽으면 교체 fork,
|
|
14
|
+
* SIGTERM/SIGINT 에 워커들을 graceful drain 후 종료한다.
|
|
13
15
|
*/
|
|
16
|
+
import cluster from 'node:cluster';
|
|
17
|
+
import { availableParallelism } from 'node:os';
|
|
14
18
|
import { loadDotEnv } from '@gaonjs/core';
|
|
15
19
|
import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
|
|
16
20
|
import { registerTsResolve } from './tsResolve.js';
|
|
17
21
|
import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
|
|
22
|
+
/**
|
|
23
|
+
* 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
|
|
24
|
+
* (availableParallelism). 0·음수·비수치는 1 로 떨어진다(안전 기본). 결정 84.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveWorkerCount(workers, env = process.env) {
|
|
27
|
+
const raw = workers ?? env.WEB_CONCURRENCY;
|
|
28
|
+
if (raw === undefined || raw === '')
|
|
29
|
+
return 1;
|
|
30
|
+
if (raw === 'auto')
|
|
31
|
+
return Math.max(1, availableParallelism());
|
|
32
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
33
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
34
|
+
return 1;
|
|
35
|
+
return Math.floor(n);
|
|
36
|
+
}
|
|
18
37
|
function humanEvent(e) {
|
|
19
38
|
switch (e.kind) {
|
|
20
39
|
case 'starting': {
|
|
@@ -24,17 +43,81 @@ function humanEvent(e) {
|
|
|
24
43
|
}
|
|
25
44
|
case 'listening':
|
|
26
45
|
return ` ▶ 리슨 중 — ${e.url} (Ctrl+C 로 종료)`;
|
|
46
|
+
case 'cluster':
|
|
47
|
+
return ` gaon serve · 클러스터 — 워커 ${e.workers}개 fork (node:cluster)`;
|
|
48
|
+
case 'worker-exit':
|
|
49
|
+
return ` ⚠ 워커 종료(pid ${e.pid ?? '?'} · code ${e.code}${e.signal ? ` · ${e.signal}` : ''})${e.restarted ? ' — 교체 fork' : ''}`;
|
|
27
50
|
case 'stopping':
|
|
28
51
|
return ' gaon serve · 종료 중 (graceful) ...';
|
|
29
52
|
case 'stopped':
|
|
30
53
|
return ' gaon serve · 종료';
|
|
31
54
|
}
|
|
32
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* 클러스터 프라이머리 — N 워커를 fork 하고 감독한다. 워커가 예기치 않게 죽으면
|
|
58
|
+
* 교체 fork(복원력), SIGTERM/SIGINT 에 워커들을 SIGTERM 으로 graceful drain 한 뒤
|
|
59
|
+
* (상한 `GAON_WORKER_DRAIN_MS`, 기본 30s) SIGKILL 로 강제 종료한다. 결정 84.
|
|
60
|
+
*/
|
|
61
|
+
async function runClusterPrimary(workerCount, opts) {
|
|
62
|
+
const json = opts.json ?? false;
|
|
63
|
+
const signals = opts.signals ?? process;
|
|
64
|
+
const drainMs = process.env.GAON_WORKER_DRAIN_MS ? Number(process.env.GAON_WORKER_DRAIN_MS) : 30_000;
|
|
65
|
+
const emit = (e) => {
|
|
66
|
+
if (json)
|
|
67
|
+
process.stdout.write(JSON.stringify(e) + '\n');
|
|
68
|
+
else
|
|
69
|
+
process.stdout.write(humanEvent(e) + '\n');
|
|
70
|
+
};
|
|
71
|
+
emit({ kind: 'cluster', workers: workerCount });
|
|
72
|
+
let shuttingDown = false;
|
|
73
|
+
for (let i = 0; i < workerCount; i++)
|
|
74
|
+
cluster.fork();
|
|
75
|
+
cluster.on('exit', (worker, code, signal) => {
|
|
76
|
+
if (shuttingDown)
|
|
77
|
+
return;
|
|
78
|
+
// 예기치 않은 종료 → 교체 fork 로 워커 수를 유지한다.
|
|
79
|
+
emit({ kind: 'worker-exit', pid: worker.process.pid, code, signal, restarted: true });
|
|
80
|
+
cluster.fork();
|
|
81
|
+
});
|
|
82
|
+
await new Promise((resolvePromise) => {
|
|
83
|
+
const stop = () => {
|
|
84
|
+
if (shuttingDown)
|
|
85
|
+
return;
|
|
86
|
+
shuttingDown = true;
|
|
87
|
+
signals.off('SIGINT', stop);
|
|
88
|
+
signals.off('SIGTERM', stop);
|
|
89
|
+
emit({ kind: 'stopping' });
|
|
90
|
+
for (const w of Object.values(cluster.workers ?? {}))
|
|
91
|
+
w?.kill('SIGTERM');
|
|
92
|
+
const killTimer = setTimeout(() => {
|
|
93
|
+
for (const w of Object.values(cluster.workers ?? {}))
|
|
94
|
+
w?.kill('SIGKILL');
|
|
95
|
+
}, drainMs);
|
|
96
|
+
const check = () => {
|
|
97
|
+
if (Object.keys(cluster.workers ?? {}).length === 0) {
|
|
98
|
+
clearTimeout(killTimer);
|
|
99
|
+
emit({ kind: 'stopped' });
|
|
100
|
+
resolvePromise();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
cluster.on('exit', check);
|
|
104
|
+
check();
|
|
105
|
+
};
|
|
106
|
+
signals.on('SIGINT', stop);
|
|
107
|
+
signals.on('SIGTERM', stop);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
33
110
|
/**
|
|
34
111
|
* `gaon serve` 진입점. loadDotEnv → loadGaonConfig → wireGaon → listen →
|
|
35
112
|
* SIGINT 대기 → graceful close. 예외는 stderr + exit 1.
|
|
36
113
|
*/
|
|
37
114
|
export async function runServeCommand(opts = {}) {
|
|
115
|
+
// 워커 다중화(결정 84): 2 이상이고 이 프로세스가 프라이머리면 감독만 한다.
|
|
116
|
+
// 워커(cluster.isWorker)와 단일 프로세스(count=1)는 아래 서버 본문을 실행한다.
|
|
117
|
+
const workerCount = resolveWorkerCount(opts.workers);
|
|
118
|
+
if (workerCount > 1 && cluster.isPrimary) {
|
|
119
|
+
return runClusterPrimary(workerCount, opts);
|
|
120
|
+
}
|
|
38
121
|
const cwd = opts.cwd ?? process.cwd();
|
|
39
122
|
const json = opts.json ?? false;
|
|
40
123
|
const signals = opts.signals ?? process;
|
|
@@ -66,6 +149,13 @@ export async function runServeCommand(opts = {}) {
|
|
|
66
149
|
await wired.app.listen({ host, port });
|
|
67
150
|
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
|
68
151
|
emit({ kind: 'listening', host, port, url: `http://${displayHost}:${port}` });
|
|
152
|
+
// 결정 98: serve 는 빌드된 번들을 서빙하며 코드 변경을 감시하지 않는다 —
|
|
153
|
+
// 수정이 조용히 반영 안 되는 혼란(첫 실사용 관측)을 막으려 한 줄 안내한다.
|
|
154
|
+
// dev 자식(gaon dev · opts.dev)은 이미 감시하므로 그때는 안내하지 않고,
|
|
155
|
+
// production 은 운영 로그 소음을 막으려 출력하지 않는다. json 은 파싱 안전상 제외.
|
|
156
|
+
if (!json && !opts.dev && process.env.NODE_ENV !== 'production') {
|
|
157
|
+
process.stdout.write(' ℹ serve 는 빌드된 번들을 서빙하며 코드 변경을 감시하지 않습니다 → 개발 중이면 `gaon dev` 를 쓰세요.\n');
|
|
158
|
+
}
|
|
69
159
|
await new Promise((resolvePromise) => {
|
|
70
160
|
const stop = () => {
|
|
71
161
|
signals.off('SIGINT', stop);
|
|
@@ -85,4 +175,9 @@ export async function runServeCommand(opts = {}) {
|
|
|
85
175
|
signals.on('SIGINT', stop);
|
|
86
176
|
signals.on('SIGTERM', stop);
|
|
87
177
|
});
|
|
178
|
+
// 클러스터 워커는 graceful close 후에도 cluster IPC 채널이 이벤트 루프를 잡아
|
|
179
|
+
// 프로세스가 안 죽는다 → 프라이머리가 워커 소멸을 감지 못 해 매달린다. close 가
|
|
180
|
+
// 끝난 뒤 명시적으로 종료한다(안전). 단일 프로세스는 자연 종료(호출 안 함). 결정 84.
|
|
181
|
+
if (cluster.isWorker)
|
|
182
|
+
process.exit(0);
|
|
88
183
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { pageProps, useForm } from 'gaonjs/vue'
|
|
2
|
+
import { pageProps, useForm, Link } from 'gaonjs/vue'
|
|
3
3
|
import Card from '../../components/ui/Card.vue'
|
|
4
4
|
import CardHeader from '../../components/ui/CardHeader.vue'
|
|
5
5
|
import CardTitle from '../../components/ui/CardTitle.vue'
|
|
@@ -42,7 +42,7 @@ const form = useForm({ email: '', password: '', _csrf: csrf })
|
|
|
42
42
|
</Form>
|
|
43
43
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
44
44
|
계정이 없으신가요?
|
|
45
|
-
<
|
|
45
|
+
<Link href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
|
|
46
46
|
</p>
|
|
47
47
|
</CardContent>
|
|
48
48
|
</Card>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { pageProps, useForm } from 'gaonjs/vue'
|
|
2
|
+
import { pageProps, useForm, Link } from 'gaonjs/vue'
|
|
3
3
|
import Card from '../../components/ui/Card.vue'
|
|
4
4
|
import CardHeader from '../../components/ui/CardHeader.vue'
|
|
5
5
|
import CardTitle from '../../components/ui/CardTitle.vue'
|
|
@@ -43,7 +43,7 @@ const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
|
|
|
43
43
|
</Form>
|
|
44
44
|
<p class="mt-4 text-center text-sm text-muted-foreground">
|
|
45
45
|
이미 계정이 있으신가요?
|
|
46
|
-
<
|
|
46
|
+
<Link href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</Link>
|
|
47
47
|
</p>
|
|
48
48
|
</CardContent>
|
|
49
49
|
</Card>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// 웹 앱 팩토리 — gaon g auth 스캐폴드. createWebApp 으로 세션·인증을 배선한다.
|
|
2
|
+
import { createApp, type AppSessionOptions } from 'gaonjs/web'
|
|
3
|
+
import appRoutes from './routes.js'
|
|
4
|
+
import session from './controllers/session.js'
|
|
5
|
+
import registration from './controllers/registration.js'
|
|
6
|
+
import dashboard from './controllers/dashboard.js'
|
|
7
|
+
import { loadUser } from './auth.js'
|
|
8
|
+
|
|
9
|
+
export interface WebAppDeps {
|
|
10
|
+
/** 세션 설정 — { redisUrl, secret } (또는 redis 인스턴스). */
|
|
11
|
+
readonly session: AppSessionOptions
|
|
12
|
+
/** 서명 쿠키/CSRF 용 비밀. */
|
|
13
|
+
readonly cookieSecret?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createWebApp(deps: WebAppDeps) {
|
|
17
|
+
return createApp({
|
|
18
|
+
apps: [
|
|
19
|
+
{
|
|
20
|
+
name: '{{APP_NAME}}',
|
|
21
|
+
routes: appRoutes,
|
|
22
|
+
controllers: { session, registration, dashboard },
|
|
23
|
+
session: deps.session,
|
|
24
|
+
auth: { loadUser, loginRedirect: '/session/new' },
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
cookieSecret: deps.cookieSecret,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// 서버 진입점 — gaon g auth 스캐폴드. `node dist/server.js` 로 실행.
|
|
2
|
+
import { createWebApp } from './apps/{{APP_NAME}}/app.js'
|
|
3
|
+
|
|
4
|
+
const app = await createWebApp({
|
|
5
|
+
session: {
|
|
6
|
+
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
|
|
7
|
+
secret: process.env.SESSION_SECRET ?? 'change-me-to-a-32+char-random-secret!!',
|
|
8
|
+
},
|
|
9
|
+
cookieSecret: process.env.COOKIE_SECRET,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const port = Number(process.env.PORT ?? 3000)
|
|
13
|
+
await app.listen({ port, host: '0.0.0.0' })
|
|
14
|
+
console.log(`web 앱이 http://localhost:${port} 에서 실행 중입니다.`)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
이 문서는 **AI 코딩 에이전트**(Claude · Codex · Cursor · Copilot 등)와
|
|
4
4
|
사람 개발자가 Gaon 프로젝트에서 작업할 때 참조하는 관례의 진입점이다.
|
|
5
|
-
정본은 설계 문서(
|
|
6
|
-
문서는 **2층 구조**다 (결정 40):
|
|
5
|
+
정본은 설계 문서(`docs/gaondesignv0.17.md` · v1.0 출시 기준 스냅샷 ·
|
|
6
|
+
v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구조**다 (결정 40):
|
|
7
7
|
|
|
8
8
|
- **이 파일 (코어)** — 절대 규칙 · 로직 배치 판단표 · 검증 루프 ·
|
|
9
9
|
카테고리 색인. 여기엔 요약만 있다.
|
|
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
104
104
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
105
105
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
106
106
|
|
|
107
|
-
### 2.2 `gaon doctor` 검사
|
|
107
|
+
### 2.2 `gaon doctor` 검사 19종
|
|
108
108
|
|
|
109
109
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
110
110
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -120,6 +120,11 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
120
120
|
12. `page-filename` — Vue 페이지 파일명 PascalCase 관례 (결정 32·46)
|
|
121
121
|
13. `auth-wiring` — requireAuth/this.auth 사용 ↔ `app.config.ts` 인증 배선 (결정 59)
|
|
122
122
|
14. `ui-kit-wiring` — UI 킷 컴포넌트 import ↔ `apps/<앱>/style.css` Tailwind 배선 (결정 76 · 경고)
|
|
123
|
+
15. `route-registration` — 고아 컨트롤러(파일은 있는데 `routes.ts` 미참조 · 도달 불가) (결정 79 · 경고)
|
|
124
|
+
16. `static-collision` — 정적 파일(`apps/<앱>/static/`)이 라우트/에셋에 가려져 도달 불가 (결정 85 · 경고)
|
|
125
|
+
17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
|
|
126
|
+
18. `csrf-wiring` — 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 `app.config.ts` 에 session 미배선 = CSRF 무방비 (결정 93 · 경고)
|
|
127
|
+
19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
|
|
123
128
|
|
|
124
129
|
## 3. 로직 배치 One Way 판단표
|
|
125
130
|
|
|
@@ -160,7 +165,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
160
165
|
```bash
|
|
161
166
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
|
|
162
167
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
163
|
-
gaon doctor # 정적 검사
|
|
168
|
+
gaon doctor # 정적 검사 19종 (§2.2)
|
|
164
169
|
```
|
|
165
170
|
|
|
166
171
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -168,8 +173,8 @@ gaon doctor # 정적 검사 14종 (§2.2)
|
|
|
168
173
|
| 명령 | 역할 |
|
|
169
174
|
|---|---|
|
|
170
175
|
| `gaon new <name>` | 프로젝트 스캐폴드 |
|
|
171
|
-
| `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon`
|
|
172
|
-
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) |
|
|
176
|
+
| `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**코드 변경 감시·재시작**) |
|
|
177
|
+
| `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** |
|
|
173
178
|
| `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
|
|
174
179
|
| `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
|
|
175
180
|
| `gaon check` / `test` / `doctor` | 검증 루프 |
|
|
@@ -181,6 +186,11 @@ gaon doctor # 정적 검사 14종 (§2.2)
|
|
|
181
186
|
으로 더듬는 대신 프레임웍에게 직접 묻는다. `read_agent_doc` 은 §0
|
|
182
187
|
카테고리 문서를 조회한다 (결정 40).
|
|
183
188
|
|
|
189
|
+
**개발 중엔 `gaon dev`, 배포 실행은 `gaon serve`** (결정 98) — `gaon serve` 는
|
|
190
|
+
빌드된 번들을 서빙하며 **코드 변경을 감시하지 않는다**(수정해도 반영 안 됨).
|
|
191
|
+
개발 중이면 `gaon dev`(감시·재시작·`.gaon` 재생성 통합)를 쓴다. `serve` 는
|
|
192
|
+
비-production 부팅 시 이 안내를 한 줄 출력한다.
|
|
193
|
+
|
|
184
194
|
## 5. npm 배포본 (2026-07-23 실측 · `npm view <pkg> version`)
|
|
185
195
|
|
|
186
196
|
| 패키지 | 버전 | 역할 |
|
|
@@ -210,7 +220,8 @@ gaon doctor # 정적 검사 14종 (§2.2)
|
|
|
210
220
|
|
|
211
221
|
## 7. 참고 문서
|
|
212
222
|
|
|
213
|
-
- 설계 정본: `docs/gaondesignv0.
|
|
223
|
+
- 설계 정본: `docs/gaondesignv0.17.md` (v1.0 출시 기준 스냅샷 · 결정 31~89) ·
|
|
224
|
+
이력 동결 = `gaondesignv0.16.md`·`v0.15.md` + errata E-1~E-5
|
|
214
225
|
(E-1 파사드명 · E-2 실시간 TCP · E-3 JSON 액션/params · E-4 컬럼·
|
|
215
226
|
체이닝 · E-5 컴포저블·레이아웃).
|
|
216
227
|
- 가이드: `docs/guides/*.md` (getting-started · data · data-flow ·
|
|
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
87
|
gaon check # .gaon 재생성 후 타입 검사 (CI 정합)
|
|
88
|
-
gaon doctor # 정적 검사
|
|
88
|
+
gaon doctor # 정적 검사 17종 (응답·N+1·의존·커넥션·마이그·순수·자동import·파일명/컬럼·인증·UI킷·라우트 · 상세 AGENTS §2.2)
|
|
89
89
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# {{PROJECT_NAME}} 운영 이미지 — Node 22 · pnpm · TS 네이티브(gaon serve)
|
|
2
|
+
#
|
|
3
|
+
# gaonjs 는 TS 네이티브(Node 타입 스트리핑)라 별도 tsc 빌드가 없다. `vite build`
|
|
4
|
+
# 로 프론트 번들만 만들고, 런타임은 소스 + 번들을 `gaon serve` 로 그대로 돌린다.
|
|
5
|
+
# 웹·워커·허브 프로세스는 compose.prod.yaml 이 같은 이미지로 command 만 바꿔 띄운다.
|
|
6
|
+
|
|
7
|
+
FROM node:22-slim AS base
|
|
8
|
+
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
|
|
9
|
+
RUN corepack enable
|
|
10
|
+
WORKDIR /app
|
|
11
|
+
|
|
12
|
+
# 1) 의존 설치 — lockfile 로 재현 가능하게(빌드에 dev 의존 필요).
|
|
13
|
+
FROM base AS deps
|
|
14
|
+
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
|
|
15
|
+
RUN pnpm install --frozen-lockfile
|
|
16
|
+
|
|
17
|
+
# 2) 프론트 번들 빌드(vite build).
|
|
18
|
+
FROM base AS build
|
|
19
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
20
|
+
COPY . .
|
|
21
|
+
RUN pnpm build
|
|
22
|
+
|
|
23
|
+
# 3) 런타임 — 소스 + 번들 + 의존을 그대로 실행.
|
|
24
|
+
FROM base AS runtime
|
|
25
|
+
ENV NODE_ENV=production
|
|
26
|
+
COPY --from=build /app ./
|
|
27
|
+
# 웹 서버 포트(gaon.config.ts 의 web.port · 기본 3000). 프록시 뒤에 둔다.
|
|
28
|
+
EXPOSE 3000
|
|
29
|
+
# 웹 프로세스. 워커(gaon work)·허브(gaon hub)는 compose.prod.yaml 의 별도 서비스.
|
|
30
|
+
CMD ["pnpm", "serve"]
|
|
@@ -143,6 +143,10 @@ export const PlaceOrder = service(async (input: { name: string }) => {
|
|
|
143
143
|
- at-least-once — 발행 후 표시하므로 중복 가능성이 있고, dedup(msgID)이
|
|
144
144
|
흡수한다.
|
|
145
145
|
- 아웃박스 테이블(`_gaon_outbox`)은 코어 내장이며 워커 기동 시 보장된다.
|
|
146
|
+
- 발행 완료 행은 릴레이가 **자동 정리(purge)** 한다 — 기본 7일 보존 후 삭제
|
|
147
|
+
(결정 78). 수동 cleanup 코드를 쓰지 말 것. 보존 기간·간격은 `gaon work` 의
|
|
148
|
+
`outboxRetentionMs`·`outboxPurgeIntervalMs` 로 조정한다(운영 상세는
|
|
149
|
+
`docs/guides/operations.md`). 미발행 행은 절대 삭제되지 않는다.
|
|
146
150
|
|
|
147
151
|
### 5. 스케줄러
|
|
148
152
|
|