@gaonjs/cli 0.35.0 → 0.37.1
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/commands/check.js +15 -9
- package/dist/commands/dev.js +10 -1
- package/dist/commands/gen.d.ts +2 -0
- package/dist/commands/gen.js +6 -2
- package/dist/commands/new.d.ts +7 -1
- package/dist/commands/new.js +15 -2
- package/dist/commands/test.d.ts +2 -1
- package/dist/commands/test.js +11 -5
- package/dist/dev.d.ts +13 -1
- package/dist/dev.js +21 -1
- package/dist/doctor/no-import-meta-env.d.ts +5 -0
- package/dist/doctor/no-import-meta-env.js +98 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +9 -0
- package/dist/doctor.js +12 -3
- package/dist/env-gen.d.ts +12 -0
- package/dist/env-gen.js +63 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +54 -23
- package/dist/pm.d.ts +13 -0
- package/dist/pm.js +59 -0
- package/dist/templates/index.d.ts +17 -0
- package/dist/templates/index.js +18 -1
- package/dist/templates/index.ts +24 -0
- package/dist/templates/project/.env.example.tpl +6 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/async.md.tpl +36 -3
- package/dist/templates/project/agents/frontend.md.tpl +52 -1
- package/dist/templates/project/agents/i18n.md.tpl +26 -0
- package/dist/templates/project/agents/mail.md.tpl +31 -0
- package/dist/templates/project/agents/realtime.md.tpl +10 -0
- package/dist/templates/project/agents/testing.md.tpl +8 -4
- package/dist/templates/project/agents/web.md.tpl +36 -0
- package/dist/templates/project/package.json.tpl +1 -1
- package/package.json +9 -9
- package/dist/templates/auth/app.ts.tpl +0 -29
- package/dist/templates/auth/server.ts.tpl +0 -14
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import { runServeCommand } from "./serve.js";
|
|
|
26
26
|
import { runWorkCommand } from "./work.js";
|
|
27
27
|
import { runJobsCommand } from "./jobs.js";
|
|
28
28
|
import { runDbCommand } from "./commands/db.js";
|
|
29
|
-
import { runDoctorCommand } from "./doctor.js";
|
|
29
|
+
import { runDoctorCommand, ALL_RULES } from "./doctor.js";
|
|
30
30
|
import { runMcpCommand } from "./commands/mcp.js";
|
|
31
31
|
export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
|
|
32
32
|
export { runDevCommand } from "./commands/dev.js";
|
|
@@ -95,7 +95,7 @@ function renderHelp(version = VERSION) {
|
|
|
95
95
|
"",
|
|
96
96
|
" 사용법:",
|
|
97
97
|
" gaon 로드맵과 개발 상태를 출력",
|
|
98
|
-
" gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --
|
|
98
|
+
" gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --package-manager <pnpm|npm|yarn>)",
|
|
99
99
|
" gaon dev 개발 스택 통합 (Docker · .gaon · serve · tsc/vue-tsc · 재시작 워처)",
|
|
100
100
|
" gaon dev --stop-docker Ctrl+C 시 Docker Compose 도 down",
|
|
101
101
|
" gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker 개별 debug 옵션",
|
|
@@ -146,18 +146,10 @@ function renderHelp(version = VERSION) {
|
|
|
146
146
|
* 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
|
|
147
147
|
*/
|
|
148
148
|
export function parseDoctorChecks(argv) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"connections",
|
|
154
|
-
"migration-diff",
|
|
155
|
-
"shared-composable-purity",
|
|
156
|
-
"no-auto-import",
|
|
157
|
-
"csrf-wiring",
|
|
158
|
-
"async-offload",
|
|
159
|
-
];
|
|
160
|
-
const isKnown = (s) => known.includes(s);
|
|
149
|
+
// 인정 집합은 doctor.ts 의 ALL_RULES(정본 26종)를 단일 출처로 쓴다 — 과거
|
|
150
|
+
// 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
|
|
151
|
+
// 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
|
|
152
|
+
const isKnown = (s) => ALL_RULES.includes(s);
|
|
161
153
|
const out = [];
|
|
162
154
|
for (const a of argv) {
|
|
163
155
|
if (a.startsWith("--check=")) {
|
|
@@ -169,6 +161,41 @@ export function parseDoctorChecks(argv) {
|
|
|
169
161
|
}
|
|
170
162
|
return out.length ? out : undefined;
|
|
171
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* `gaon new <name>` 의 인자를 파싱한다.
|
|
166
|
+
* 패키지 매니저 플래그: `--package-manager <pm>`(정본) · `--pm <pm>`(별칭) ·
|
|
167
|
+
* 둘 다 `=` 형(`--package-manager=npm`)도 허용. 값을 취하는 플래그이므로
|
|
168
|
+
* 이름 위치 인자를 고를 때 그 값을 건너뛴다 — `gaon new --pm npm demo` 의
|
|
169
|
+
* npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
|
|
170
|
+
*/
|
|
171
|
+
export function parseNewArgs(rest) {
|
|
172
|
+
let name;
|
|
173
|
+
let pmRaw;
|
|
174
|
+
for (let i = 0; i < rest.length; i++) {
|
|
175
|
+
const a = rest[i];
|
|
176
|
+
if (a === "--package-manager" || a === "--pm") {
|
|
177
|
+
pmRaw = rest[++i];
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const eqPrefix = a.startsWith("--package-manager=")
|
|
181
|
+
? "--package-manager="
|
|
182
|
+
: a.startsWith("--pm=")
|
|
183
|
+
? "--pm="
|
|
184
|
+
: undefined;
|
|
185
|
+
if (eqPrefix !== undefined) {
|
|
186
|
+
pmRaw = a.slice(eqPrefix.length);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (a.startsWith("--"))
|
|
190
|
+
continue;
|
|
191
|
+
if (name === undefined)
|
|
192
|
+
name = a;
|
|
193
|
+
}
|
|
194
|
+
if (pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn") {
|
|
195
|
+
return { name, packageManager: pmRaw };
|
|
196
|
+
}
|
|
197
|
+
return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
|
|
198
|
+
}
|
|
172
199
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
173
200
|
export function runCli(argv, opts = {}) {
|
|
174
201
|
const version = opts.version ?? VERSION;
|
|
@@ -460,19 +487,23 @@ export function runCli(argv, opts = {}) {
|
|
|
460
487
|
// `gaon new <name>` — 프로젝트 스캐폴드(M9-F). 파일 생성 → 의존성 설치 → git init.
|
|
461
488
|
if (argv[0] === "new") {
|
|
462
489
|
const rest = argv.slice(1);
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (!a.startsWith("--") && name === undefined)
|
|
466
|
-
name = a;
|
|
467
|
-
}
|
|
468
|
-
if (!name) {
|
|
490
|
+
const parsed = parseNewArgs(rest);
|
|
491
|
+
if (!parsed.name) {
|
|
469
492
|
process.stderr.write(" ✗ gaon new: 프로젝트 이름이 없습니다.\n → 예: gaon new demo\n");
|
|
470
493
|
process.exitCode = 1;
|
|
471
494
|
return;
|
|
472
495
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
496
|
+
// 알 수 없는 pm 값은 조용히 pnpm 으로 되돌리지 않고 사용자에게 알린다
|
|
497
|
+
// (에러가 곧 수리 안내서 · §7.5.3). 이전엔 무시돼 --package-manager npm 이
|
|
498
|
+
// pnpm 으로 설치되던 O-1 표류를 근본 차단한다(결정 167).
|
|
499
|
+
if (parsed.unknownPm !== undefined) {
|
|
500
|
+
process.stderr.write(` ✗ gaon new: 알 수 없는 패키지 매니저 '${parsed.unknownPm}'.\n` +
|
|
501
|
+
` → --package-manager 는 pnpm · npm · yarn 만 지원합니다(기본 pnpm).\n`);
|
|
502
|
+
process.exitCode = 1;
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const name = parsed.name;
|
|
506
|
+
const packageManager = parsed.packageManager;
|
|
476
507
|
void runNewCommand(name, {
|
|
477
508
|
json: argv.includes("--json"),
|
|
478
509
|
skipInstall: argv.includes("--skip-install"),
|
package/dist/pm.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type PackageManager = 'pnpm' | 'npm' | 'yarn';
|
|
2
|
+
/**
|
|
3
|
+
* 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는 package.json 의
|
|
4
|
+
* `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을 기록하므로 그것을
|
|
5
|
+
* 최우선으로 삼고, 없으면 락파일, 그래도 없으면 pnpm(골든 경로 기본).
|
|
6
|
+
*/
|
|
7
|
+
export declare function detectPackageManager(cwd: string): PackageManager;
|
|
8
|
+
/**
|
|
9
|
+
* `<pm> run <script>` 실행 인자를 pm 별 passthrough 관례에 맞춰 만든다. 잔여
|
|
10
|
+
* 인자가 있을 때 pnpm·npm 은 `--` 로 스크립트에 분리 전달해야 하고(그래야 vitest
|
|
11
|
+
* 필터 등이 도달), yarn(classic)은 `--` 없이 직접 전달한다(결정 170 W1).
|
|
12
|
+
*/
|
|
13
|
+
export declare function scriptRunArgs(pm: PackageManager, script: string, extras?: readonly string[]): string[];
|
package/dist/pm.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gaonjs/cli · 패키지 매니저 해상 — pm-awareness 단일 소스 (결정 170)
|
|
3
|
+
*
|
|
4
|
+
* CLI 는 대부분 도구를 직접 spawn 한다(vitest·vite·tsc·vue-tsc·node·docker =
|
|
5
|
+
* pm 무관). pm 이 관여하는 접점은 **딱 셋** 뿐이다:
|
|
6
|
+
* 1) `gaon new` 초기 install (`new.ts` · `--package-manager` 선택)
|
|
7
|
+
* 2) `gaon check` build 등 user script (`commands/check.ts`)
|
|
8
|
+
* 3) `gaon test` test user script (`commands/test.ts`)
|
|
9
|
+
*
|
|
10
|
+
* (2)·(3) 은 사용자 스크립트를 **프로젝트가 선언한 pm** 으로 돌려야 한다 —
|
|
11
|
+
* pnpm 하드코딩은 npm/yarn 로 스캐폴드한 프로젝트에서 pnpm 이 "This project is
|
|
12
|
+
* configured to use npm" 으로 실행을 거부해 깨진다. 이 해상 로직을 한 모듈에
|
|
13
|
+
* 모아 check·test 가 **같은 함수**를 쓴다(중복 하드코딩 재발 차단 · 결정 170 W2).
|
|
14
|
+
*
|
|
15
|
+
* anti-creep(결정 170 W3): 새 CLI 명령은 도구를 직접 부른다(pm 무관). 부득이
|
|
16
|
+
* pm 이 필요하면 4번째 하드코딩을 만들지 말고 **반드시 이 모듈을 경유**한다.
|
|
17
|
+
* pnpm = 골든/보장 경로 · npm/yarn = best-effort 탈출구(§6 · CLAUDE.md).
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
/**
|
|
22
|
+
* 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는 package.json 의
|
|
23
|
+
* `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을 기록하므로 그것을
|
|
24
|
+
* 최우선으로 삼고, 없으면 락파일, 그래도 없으면 pnpm(골든 경로 기본).
|
|
25
|
+
*/
|
|
26
|
+
export function detectPackageManager(cwd) {
|
|
27
|
+
const pkgPath = join(cwd, 'package.json');
|
|
28
|
+
if (existsSync(pkgPath)) {
|
|
29
|
+
try {
|
|
30
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
31
|
+
const pm = pkg.packageManager?.split('@')[0];
|
|
32
|
+
if (pm === 'pnpm' || pm === 'npm' || pm === 'yarn')
|
|
33
|
+
return pm;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// 파싱 실패는 락파일/기본으로 폴백
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (existsSync(join(cwd, 'pnpm-lock.yaml')))
|
|
40
|
+
return 'pnpm';
|
|
41
|
+
if (existsSync(join(cwd, 'yarn.lock')))
|
|
42
|
+
return 'yarn';
|
|
43
|
+
if (existsSync(join(cwd, 'package-lock.json')))
|
|
44
|
+
return 'npm';
|
|
45
|
+
return 'pnpm';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `<pm> run <script>` 실행 인자를 pm 별 passthrough 관례에 맞춰 만든다. 잔여
|
|
49
|
+
* 인자가 있을 때 pnpm·npm 은 `--` 로 스크립트에 분리 전달해야 하고(그래야 vitest
|
|
50
|
+
* 필터 등이 도달), yarn(classic)은 `--` 없이 직접 전달한다(결정 170 W1).
|
|
51
|
+
*/
|
|
52
|
+
export function scriptRunArgs(pm, script, extras = []) {
|
|
53
|
+
const base = ['run', script];
|
|
54
|
+
if (extras.length === 0)
|
|
55
|
+
return base;
|
|
56
|
+
if (pm === 'yarn')
|
|
57
|
+
return [...base, ...extras];
|
|
58
|
+
return [...base, '--', ...extras];
|
|
59
|
+
}
|
|
@@ -7,7 +7,24 @@ export interface ProjectFile {
|
|
|
7
7
|
export interface ProjectTemplateTokens {
|
|
8
8
|
readonly projectName: string;
|
|
9
9
|
readonly gaonjsVersion: string;
|
|
10
|
+
/**
|
|
11
|
+
* package.json 의 `packageManager` 필드(corepack 핀 · 예 `pnpm@10.27.0`).
|
|
12
|
+
* 미지정 시 blessed 기본 pnpm. 선택한 pm 에 맞춰 채운다(결정 169).
|
|
13
|
+
*/
|
|
14
|
+
readonly packageManager?: string;
|
|
10
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
|
|
18
|
+
* 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
|
|
19
|
+
* 12차 실사용 yarn 파손). 정본 버전 근거:
|
|
20
|
+
* · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
|
|
21
|
+
* · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
|
|
22
|
+
* 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
|
|
23
|
+
* · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
|
|
24
|
+
*/
|
|
25
|
+
export declare const PACKAGE_MANAGER_PINS: Readonly<Record<'pnpm' | 'npm' | 'yarn', string>>;
|
|
26
|
+
/** blessed 기본(pm 미선택 시). */
|
|
27
|
+
export declare const DEFAULT_PACKAGE_MANAGER: string;
|
|
11
28
|
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
12
29
|
export declare function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string;
|
|
13
30
|
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
package/dist/templates/index.js
CHANGED
|
@@ -12,11 +12,28 @@ import { readFileSync, readdirSync } from 'node:fs';
|
|
|
12
12
|
import { dirname, join, posix, relative, sep } from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project');
|
|
15
|
+
/**
|
|
16
|
+
* pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
|
|
17
|
+
* 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
|
|
18
|
+
* 12차 실사용 yarn 파손). 정본 버전 근거:
|
|
19
|
+
* · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
|
|
20
|
+
* · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
|
|
21
|
+
* 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
|
|
22
|
+
* · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
|
|
23
|
+
*/
|
|
24
|
+
export const PACKAGE_MANAGER_PINS = {
|
|
25
|
+
pnpm: 'pnpm@10.27.0',
|
|
26
|
+
npm: 'npm@10.9.9',
|
|
27
|
+
yarn: 'yarn@1.22.22',
|
|
28
|
+
};
|
|
29
|
+
/** blessed 기본(pm 미선택 시). */
|
|
30
|
+
export const DEFAULT_PACKAGE_MANAGER = PACKAGE_MANAGER_PINS.pnpm;
|
|
15
31
|
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
16
32
|
export function renderTemplate(raw, tokens) {
|
|
17
33
|
return raw
|
|
18
34
|
.replaceAll('{{PROJECT_NAME}}', tokens.projectName)
|
|
19
|
-
.replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
|
|
35
|
+
.replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
|
|
36
|
+
.replaceAll('{{PACKAGE_MANAGER}}', tokens.packageManager ?? DEFAULT_PACKAGE_MANAGER);
|
|
20
37
|
}
|
|
21
38
|
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
|
22
39
|
export function listTemplateFiles(root = TEMPLATE_DIR) {
|
package/dist/templates/index.ts
CHANGED
|
@@ -23,15 +23,39 @@ export interface ProjectFile {
|
|
|
23
23
|
export interface ProjectTemplateTokens {
|
|
24
24
|
readonly projectName: string
|
|
25
25
|
readonly gaonjsVersion: string
|
|
26
|
+
/**
|
|
27
|
+
* package.json 의 `packageManager` 필드(corepack 핀 · 예 `pnpm@10.27.0`).
|
|
28
|
+
* 미지정 시 blessed 기본 pnpm. 선택한 pm 에 맞춰 채운다(결정 169).
|
|
29
|
+
*/
|
|
30
|
+
readonly packageManager?: string
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project')
|
|
29
34
|
|
|
35
|
+
/**
|
|
36
|
+
* pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
|
|
37
|
+
* 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
|
|
38
|
+
* 12차 실사용 yarn 파손). 정본 버전 근거:
|
|
39
|
+
* · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
|
|
40
|
+
* · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
|
|
41
|
+
* 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
|
|
42
|
+
* · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
|
|
43
|
+
*/
|
|
44
|
+
export const PACKAGE_MANAGER_PINS: Readonly<Record<'pnpm' | 'npm' | 'yarn', string>> = {
|
|
45
|
+
pnpm: 'pnpm@10.27.0',
|
|
46
|
+
npm: 'npm@10.9.9',
|
|
47
|
+
yarn: 'yarn@1.22.22',
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** blessed 기본(pm 미선택 시). */
|
|
51
|
+
export const DEFAULT_PACKAGE_MANAGER = PACKAGE_MANAGER_PINS.pnpm
|
|
52
|
+
|
|
30
53
|
/** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
|
|
31
54
|
export function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string {
|
|
32
55
|
return raw
|
|
33
56
|
.replaceAll('{{PROJECT_NAME}}', tokens.projectName)
|
|
34
57
|
.replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
|
|
58
|
+
.replaceAll('{{PACKAGE_MANAGER}}', tokens.packageManager ?? DEFAULT_PACKAGE_MANAGER)
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
/** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
|
|
@@ -30,3 +30,9 @@ COOKIE_SECRET=change-me-too-32-char-random-secret!!
|
|
|
30
30
|
|
|
31
31
|
# 리슨 포트 (gaon serve --port 로 덮음).
|
|
32
32
|
PORT=3000
|
|
33
|
+
|
|
34
|
+
# 클라이언트 공개 환경변수(결정 198) — `VITE_` 접두 변수만 브라우저 번들에 노출된다(그 외는
|
|
35
|
+
# 서버-only). `.vue`·클라 `.ts` 에서 `import { env } from 'gaonjs/vue'` 로 읽는다(import.meta.env
|
|
36
|
+
# 직접 사용은 vue-tsc TS1470 · doctor no-import-meta-env 가 잡음). 접두는 제거된다: 아래를 켜면
|
|
37
|
+
# `env.API_URL` 로 접근. `.env` 의 VITE_* 키가 `.gaon/env.d.ts` 로 물성화돼 없는 키는 컴파일 에러.
|
|
38
|
+
# VITE_API_URL=https://api.example.com
|
|
@@ -108,7 +108,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
108
108
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
109
109
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
110
110
|
|
|
111
|
-
### 2.2 `gaon doctor` 검사
|
|
111
|
+
### 2.2 `gaon doctor` 검사 26종
|
|
112
112
|
|
|
113
113
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
114
114
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -135,6 +135,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
135
135
|
23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
|
|
136
136
|
24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon check --fix` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
|
|
137
137
|
25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
|
|
138
|
+
26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
|
|
138
139
|
|
|
139
140
|
## 3. 로직 배치 One Way 판단표
|
|
140
141
|
|
|
@@ -195,7 +196,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
195
196
|
```bash
|
|
196
197
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
|
|
197
198
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
198
|
-
gaon doctor # 정적 검사
|
|
199
|
+
gaon doctor # 정적 검사 26종 (§2.2)
|
|
199
200
|
```
|
|
200
201
|
|
|
201
202
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
87
|
gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
|
|
88
|
-
gaon doctor # 정적 검사
|
|
88
|
+
gaon doctor # 정적 검사 25종 (상세 AGENTS §2.2)
|
|
89
89
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -247,21 +247,51 @@ export default schedule((s) => {
|
|
|
247
247
|
(`.later()`), 처리·스케줄은 워커가 한다. 스케줄이 안 도는 흔한 원인은
|
|
248
248
|
`gaon work` 를 안 띄운 것이다.
|
|
249
249
|
|
|
250
|
+
#### 시간대 (결정 202)
|
|
251
|
+
|
|
252
|
+
`s.daily.at('04:00', Job)`·`s.cron('0 9 * * 1', Job)` 는 **서버 로컬 타임존**의
|
|
253
|
+
wall-clock 으로 매치한다(`new Date()` 로컬 시·분·요일). v1 은 **잡별 타임존
|
|
254
|
+
옵션이 없다** — 특정 TZ 로 돌려야 하면 워커 프로세스의 `TZ` 환경변수를 그
|
|
255
|
+
타임존으로 띄운다(예: `TZ=Asia/Seoul gaon work`). 여러 인스턴스는 같은 TZ 로
|
|
256
|
+
맞춘다(리더가 어느 인스턴스든 같은 wall-clock 을 봐야 한다).
|
|
257
|
+
|
|
258
|
+
#### 중첩 방지 (결정 202)
|
|
259
|
+
|
|
260
|
+
스케줄러는 매 주기 **발행만** 한다(§7 리더). 한 주기보다 오래 걸리는 잡이
|
|
261
|
+
**자기 자신과 겹쳐** 실행되면 안 되면, 잡 핸들러가 `lock(key, fn, { onBusy:
|
|
262
|
+
'skip' })` 으로 임계구역을 지킨다 — 이미 도는 인스턴스가 있으면 이번 틱은
|
|
263
|
+
스킵된다(중복 실행 방지). 락은 `gaon work` 워커에서도 배선된다(결정 202).
|
|
264
|
+
|
|
250
265
|
### 6. 워커 프로세스 (`gaon work`)
|
|
251
266
|
|
|
252
267
|
잡·리스너·스케줄러·아웃박스 릴레이를 한 프로세스로 조립한다. 운영
|
|
253
268
|
프로세스 3종(serve·work·hub) 중 하나. SIGTERM/SIGINT 에 graceful
|
|
254
269
|
drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤 종료한다.
|
|
255
270
|
|
|
271
|
+
#### graceful drain 계약 (결정 201)
|
|
272
|
+
|
|
273
|
+
배포·재시작(k8s 롤링 등)에서 **SIGTERM 이 진행 중 작업을 자르지 않는다**:
|
|
274
|
+
|
|
275
|
+
- **`gaon serve`** — 인플라이트 HTTP 요청을 **완료까지 기다린 뒤** 종료한다(리셋
|
|
276
|
+
안 함 · 롤링 배포 중 502 방지). 새 연결은 안 받고, 진행 요청이 끝나면 idle
|
|
277
|
+
keep-alive 를 닫아 종료가 즉시 완결된다(결정 201 · `forceCloseConnections:false`
|
|
278
|
+
+ close idle 스윕).
|
|
279
|
+
- **`gaon work`** — 신규 잡 pull 을 멈추고 **진행 중 잡을 완료**한 뒤 종료한다
|
|
280
|
+
(`drainTimeoutMs` 상한 · 기본 30s). 스케줄러 리더는 즉시 반납해 다른 인스턴스가
|
|
281
|
+
승계한다. drain 상한을 넘긴 잡은 ack 되지 않아 재전달(크래시 복구)된다.
|
|
282
|
+
- **컨테이너 기본 워커 1** — `gaon serve` 클러스터(`--workers`)도 SIGTERM 에
|
|
283
|
+
워커들을 graceful drain 후 종료한다(결정 84).
|
|
284
|
+
|
|
256
285
|
### 7. 분산 락 (`lock()`) (결정 147)
|
|
257
286
|
|
|
258
287
|
**동시 실행을 막아야 하면 `lock(key, fn)`** — 같은 `key` 에 대해 전
|
|
259
288
|
인스턴스를 통틀어 동시 1개의 `fn` 만 임계구역에 들인다. **로컬 뮤텍스는
|
|
260
289
|
반정본이다** — 멀티 인스턴스(워커 여러 대·serve 여러 대)에서는 프로세스마다
|
|
261
290
|
따로 놀아 무의미하다(결정 88 ①). 그래서 백엔드는 Redis 다: 설정에 `redis`
|
|
262
|
-
가 있으면
|
|
263
|
-
|
|
264
|
-
|
|
291
|
+
가 있으면 **`gaon serve` 와 `gaon work` 둘 다** 분산 락을 자동 배선한다(결정
|
|
292
|
+
202 · wireDomain 공통 경로) — 그래서 **잡·서비스 안에서도 `lock()` 을 쓸 수
|
|
293
|
+
있다**(예: 중첩 방지 · §5). **`redis` 미설정 상태로 `lock()` 을 부르면 로컬
|
|
294
|
+
뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께 throw** 한다.
|
|
265
295
|
|
|
266
296
|
```ts
|
|
267
297
|
import { lock } from 'gaonjs/async'
|
|
@@ -344,4 +374,7 @@ async create() {
|
|
|
344
374
|
| 결정 103 | doctor `async-offload` 검사 (컨트롤러 인라인 메일·이미지·외부 HTTP 경고) |
|
|
345
375
|
| 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
|
|
346
376
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
|
|
377
|
+
| 결정 200 | DLQ 재처리 `retryDlq(nats, id)` (이미 설정된 잡 전송 보존 · DLQ 재적재 후 원본 삭제) |
|
|
378
|
+
| 결정 201 | graceful drain 계약 (serve 인플라이트 요청 완결 · work 진행 잡 완결 · §6) |
|
|
379
|
+
| 결정 202 | 락·캐시 백엔드 serve·work 공통 배선(wireDomain) · 잡/서비스 lock() 가능 · 스케줄러 시간대(서버 로컬 TZ)·중첩 방지(§5·§7) |
|
|
347
380
|
| §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
|
|
@@ -251,6 +251,24 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
251
251
|
| 원자 (18) | Button · Input · Label · Badge · Card · CardHeader · CardTitle · CardDescription · CardContent · CardFooter · Alert · AlertTitle · AlertDescription · Form · FormField · FormMessage · Dialog · Sheet |
|
|
252
252
|
| 블록 (4 · 결정 106) | PageShell · PageHeader · EmptyState · Pagination |
|
|
253
253
|
|
|
254
|
+
**슬롯·props 요약 (첫 시도용 · 소스 안 읽어도 되게 · O-2):** 카탈로그는 이름만이라 슬롯/prop 을
|
|
255
|
+
소스에서 찾아야 했다 — 자주 쓰는 표면을 여기 못박는다(전체·정확한 타입은 컴포넌트 소스가 정본).
|
|
256
|
+
**주의: named slot 이름이 비대칭이다** — `PageHeader` 는 `#actions`(**복수**), `EmptyState` 는
|
|
257
|
+
`#action`(**단수**). 기본 슬롯을 잘못 쓰면 조용히 안 그려진다.
|
|
258
|
+
|
|
259
|
+
| 컴포넌트 | props | slots | emits |
|
|
260
|
+
|---|---|---|---|
|
|
261
|
+
| **PageShell** | `size?: 'default'\|'narrow'\|'wide'\|'full'` | 기본 | — |
|
|
262
|
+
| **PageHeader** | `title?` · `description?` | `#title` · `#description` · **`#actions`**(복수) | — |
|
|
263
|
+
| **EmptyState** | `title?` · **`description?`(prop)** | `#icon` · `#title` · `#description` · **`#action`**(단수) | — |
|
|
264
|
+
| **Pagination** | `page`(필수) · `pageCount`(필수) · `siblings?=1` | — | `update:page` (= `v-model:page`) |
|
|
265
|
+
| Button | `variant?='default'` · `size?='default'` · `type?='button'` · `href?` · `external?` · `target?` | 기본 | 네이티브(예 `@click`) |
|
|
266
|
+
| FormField | `label?` · `error?` | 기본(컨트롤) | — |
|
|
267
|
+
| FormMessage | `message?` | — | — |
|
|
268
|
+
| Alert | `variant?: 'default'\|'destructive'` | 기본 | — |
|
|
269
|
+
| Badge | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'` | 기본 | — |
|
|
270
|
+
| Card / CardHeader / CardTitle / CardDescription / CardContent / CardFooter | — | 기본(조합) | — |
|
|
271
|
+
|
|
254
272
|
- **블록은 성격 중립(결정 106)** — 관리자/프론트를 나누지 않고 전 앱에서 쓴다.
|
|
255
273
|
`PageShell`(최대폭·여백·세로 리듬) · `PageHeader`(제목+설명+액션) · `EmptyState`
|
|
256
274
|
(빈 목록) · `Pagination`(페이지 이동 · `v-model:page`). 이외 블록(DataTable·StatCard·
|
|
@@ -287,7 +305,7 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
287
305
|
통째로 넘기면, 블록의 `:page`·`:pageCount` 가 필드명 그대로 붙는다(매핑 보일러플레이트 0).
|
|
288
306
|
```vue
|
|
289
307
|
<script setup lang="ts">
|
|
290
|
-
import
|
|
308
|
+
import Pagination from '@shared/components/ui/Pagination.vue'
|
|
291
309
|
import { router } from 'gaonjs/vue'
|
|
292
310
|
const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
|
|
293
311
|
function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
|
|
@@ -311,6 +329,37 @@ import PageShell from '@shared/components/ui/PageShell.vue'
|
|
|
311
329
|
`tailwind.config.ts` 의 `content` 에 `./shared/**/*.{vue,ts}` 가 있는지도 확인한다
|
|
312
330
|
(스캐폴드 기본값엔 이미 포함).
|
|
313
331
|
|
|
332
|
+
### 9. 클라이언트 환경변수 — `env` (결정 198 · F-9 옵션 ②)
|
|
333
|
+
|
|
334
|
+
`.vue`·클라 `.ts` 에서 공개 환경변수는 **`env` 접근자**로 읽는다 — `import.meta.env`
|
|
335
|
+
를 직접 쓰지 않는다. `.vue`(SFC)는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가
|
|
336
|
+
`import.meta` 를 **TS1470** 로 거부하기 때문이다(`gaon check` red). 프레임웍이
|
|
337
|
+
`import.meta.env` 를 대신 읽어 재노출하므로 페이지는 `import.meta` 를 안 쓴다.
|
|
338
|
+
|
|
339
|
+
```vue
|
|
340
|
+
<script setup lang="ts">
|
|
341
|
+
import { env } from 'gaonjs/vue'
|
|
342
|
+
|
|
343
|
+
const api = env.API_URL // .env 의 VITE_API_URL — VITE_ 접두 제거 · 타입드
|
|
344
|
+
if (env.dev) console.log(env.mode) // 내장: dev·prod·mode·baseUrl(camelCase 정규화)
|
|
345
|
+
</script>
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
- **노출 정책 = Vite 표준 `VITE_*`** — `.env` 의 `VITE_` 접두 변수만 클라 번들에
|
|
349
|
+
노출된다(그 외 `SECRET_KEY`·`DATABASE_URL` 등은 **자동 서버-only**, 번들에 절대 안 감).
|
|
350
|
+
접근자에서 접두는 제거된다: `VITE_API_URL` → `env.API_URL`.
|
|
351
|
+
- **타입 안전** — `.env` 의 VITE_* 키가 `.gaon/env.d.ts`(gen/dev/build/check 재생성)로
|
|
352
|
+
물성화돼 `gaonjs/vue` 의 `GaonClientEnv` 를 augment 한다. `env.<없는키>` 는 컴파일
|
|
353
|
+
에러(messages.d.ts 동형 · 결정 158). 키를 추가하면 `.env` 에 `VITE_...` 를 넣는다.
|
|
354
|
+
- **`.env` 필수** — 프론트 앱이 있으면 `.env` 가 없을 때 gen/dev/build/check 가 명확히
|
|
355
|
+
실패한다(수리 안내: `cp .env.example .env`). `.env` 는 앱 실행 전제다.
|
|
356
|
+
- **내장 필드**: `env.mode`(빌드 모드)·`env.dev`/`env.prod`(불리언)·`env.baseUrl`
|
|
357
|
+
(앱 base · web='/'·admin='/admin/'). `import.meta.env.MODE/DEV/PROD/BASE_URL` 대체.
|
|
358
|
+
- **doctor**: `.vue` 에서 `import.meta.env` 직접 사용은 **no-import-meta-env** 가
|
|
359
|
+
error 로 잡아 `env` 접근자로 안내한다(TS1470 을 읽기 전에).
|
|
360
|
+
- 서버 코드(컨트롤러·domain)의 환경변수는 이 접근자가 아니라 서버 env(`gaon.config.ts`
|
|
361
|
+
의 `env('KEY')`·`process.env`)로 읽는다 — `env`(gaonjs/vue)는 **클라 전용**이다.
|
|
362
|
+
|
|
314
363
|
## 정본 예시
|
|
315
364
|
|
|
316
365
|
```vue
|
|
@@ -406,6 +455,8 @@ async function runSearch(q: string) {
|
|
|
406
455
|
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
|
|
407
456
|
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
|
|
408
457
|
| 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
|
|
458
|
+
| 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
|
|
459
|
+
| 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
|
|
409
460
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
410
461
|
|
|
411
462
|
## `@gaonjs/seal` 켠 앱의 프론트
|
|
@@ -32,6 +32,29 @@ t('nav.home') // 중첩은 점 표기
|
|
|
32
32
|
| 지원 언어 | `languages(): string[]` | 설정된 supportedLngs |
|
|
33
33
|
| 고정 번역 | `runWithLanguage(lng, fn)` | fn 안의 t() 가 그 언어(메일·비요청 경로 · §mail) |
|
|
34
34
|
|
|
35
|
+
### 1.5 복수형 — `count` 로 자동 선택 (i18next 규약 · 결정 181)
|
|
36
|
+
|
|
37
|
+
복수형은 카탈로그에 **접미사 키**(`<키>_one`·`<키>_other`)를 두고 `t('<키>', { count })`
|
|
38
|
+
로 부른다 — i18next 가 `count` 와 로케일의 CLDR 규칙으로 알맞은 접미사를 고른다. 타입은
|
|
39
|
+
**base 키**(`<키>`)로 검사한다(생성기가 접미사 키에서 base 키를 함께 노출 · 결정 181).
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
// locales/en.json — 영어는 단수/복수 구분(_one·_other)
|
|
43
|
+
{ "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
|
|
44
|
+
// locales/ko.json — 한국어는 복수 구분 없음(_other 만)
|
|
45
|
+
{ "cart": { "items_other": "상품 {{count}}개" } }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
t('cart.items', { count: 1 }) // en → "1 item" · ko → "상품 1개"
|
|
50
|
+
t('cart.items', { count: 5 }) // en → "5 items" · ko → "상품 5개"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **base 키로 부른다** — `t('cart.items', { count })`. `t('cart.items_one')` 처럼 접미사를
|
|
54
|
+
직접 부르면 복수 선택이 안 된다(안티패턴).
|
|
55
|
+
- 로케일마다 필요한 접미사만 둔다(영어 `_one`·`_other` / 한국어·일본어 `_other`). 기준
|
|
56
|
+
로케일(§4)에 있는 키가 타입 유니온이 되므로 **기준 로케일 카탈로그에 복수형 키를 둔다**.
|
|
57
|
+
|
|
35
58
|
### 2. 요청별 로케일 자동 협상 (결정 159)
|
|
36
59
|
|
|
37
60
|
`gaon.config.ts` 에 `i18n` 설정이 있으면 `gaon serve`/`gaon dev` 가 **매 요청**
|
|
@@ -97,6 +120,9 @@ export function greetLine(name: string): string {
|
|
|
97
120
|
전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`.
|
|
98
121
|
- **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })` 로
|
|
99
122
|
명시한다(`agents/mail.md` · 결정 160).
|
|
123
|
+
- **검증 실패 문안도 로케일화된다** — 예약 namespace `validation.<code>`(예 `validation.required`)
|
|
124
|
+
를 `locales/` 에 넣으면 필드별 사유가 요청 로케일로 번역된다(`agents/web.md` §4.1 · 결정 183).
|
|
125
|
+
프레임웍은 코드만 노출하고 번역은 앱 몫이다(미제공 시 내장 fallback).
|
|
100
126
|
|
|
101
127
|
## 관련 결정 번호
|
|
102
128
|
|
|
@@ -57,6 +57,37 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
|
57
57
|
·`.env.example` 에 mail 블록이 이미 있어(결정 160) `cp .env.example .env` 후 바로 돈다.
|
|
58
58
|
운영은 `SMTP_HOST`·자격증명·`SMTP_SECURE=true` 로 교체(같은 코드).
|
|
59
59
|
|
|
60
|
+
### 5. `gaon.config.ts` 의 `mail` 블록 (env-gated)
|
|
61
|
+
|
|
62
|
+
메일러는 루트 `gaon.config.ts` 의 `mail` 블록으로 배선된다 — `db`·`redis`·`nats`
|
|
63
|
+
동형으로 **env-gated**(SMTP_HOST 없으면 미배선). 스캐폴드(`gaon new`)가 아래 블록을
|
|
64
|
+
이미 넣어 둔다(결정 160). 발신자 기본값은 **`defaultFrom`**(메시지 `from` 아님 · `from`
|
|
65
|
+
은 개별 메시지 override).
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// gaon.config.ts
|
|
69
|
+
export default defineConfig({
|
|
70
|
+
mail: process.env.SMTP_HOST
|
|
71
|
+
? {
|
|
72
|
+
host: process.env.SMTP_HOST,
|
|
73
|
+
port: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 1025,
|
|
74
|
+
secure: process.env.SMTP_SECURE === 'true', // 운영 TLS
|
|
75
|
+
user: process.env.SMTP_USER, // 선택(인증 SMTP)
|
|
76
|
+
pass: process.env.SMTP_PASS, // 선택
|
|
77
|
+
defaultFrom: process.env.MAIL_FROM, // 발신자 기본값(from 없는 메시지)
|
|
78
|
+
}
|
|
79
|
+
: undefined,
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| 필드 | 타입 | 비고 |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `host` | `string` | SMTP 호스트(dev = MailPit `127.0.0.1`) |
|
|
86
|
+
| `port` | `number` | SMTP 포트(dev MailPit = 1025) |
|
|
87
|
+
| `secure?` | `boolean` | TLS(운영). 생략 = false |
|
|
88
|
+
| `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능) |
|
|
89
|
+
| `defaultFrom` | `string` | 발신자 기본값 — 메시지에 `from` 이 없을 때. `configureMailer({ defaultFrom })` 와 동치 |
|
|
90
|
+
|
|
60
91
|
## 정본 예시
|
|
61
92
|
|
|
62
93
|
```ts
|
|
@@ -186,6 +186,15 @@ export function useRoom(roomId: number) {
|
|
|
186
186
|
- 허브가 죽었다 재시작하면 같은 포트로 재bind 하고 KV 에서 상태를
|
|
187
187
|
복원한다.
|
|
188
188
|
- ping 무활동 타임아웃은 네트워크 파티션 백스톱이다.
|
|
189
|
+
- **fail-fast — 리더가 됐는데 포트를 못 잡으면 즉시 종료한다(결정 207).**
|
|
190
|
+
리스는 얻었으나 TCP 포트 bind 에 실패하면(포트를 다른 허브·orphan·
|
|
191
|
+
오설정이 점유) 허브는 **좀비 리더**(리스 보유·프레즌스 서빙 불가)가 되지
|
|
192
|
+
않도록 리스를 사임하고 `process.exit(1)` 한다. 조용히 프레즌스가 죽는
|
|
193
|
+
대신 명확히 종료해 systemd/pm2 가 재시작하게 하고, 대기 인스턴스가 즉시
|
|
194
|
+
승계한다. **운영 함정**: 한 호스트에 허브를 여러 개 띄우면 포트가
|
|
195
|
+
겹친다 — 인스턴스마다 다른 `GAON_HUB_PORT` 를 주거나 호스트를 분리한다.
|
|
196
|
+
`gaon dev` 는 내장 허브를 기본 포트로 띄우므로, 같은 호스트에서 별도
|
|
197
|
+
`gaon hub` 를 돌릴 땐 포트를 바꾼다.
|
|
189
198
|
|
|
190
199
|
## 정본 예시
|
|
191
200
|
|
|
@@ -255,6 +264,7 @@ export default channel({
|
|
|
255
264
|
| §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
|
|
256
265
|
| 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
|
|
257
266
|
| 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
|
|
267
|
+
| 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
|
|
258
268
|
|
|
259
269
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
260
270
|
|
|
@@ -24,10 +24,13 @@
|
|
|
24
24
|
매 테스트 뒤 전 테이블을 비운다(`truncateAll`). 아래 §5 참고.
|
|
25
25
|
- `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
|
|
26
26
|
테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
|
|
27
|
-
`GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
`GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 같은 접두가 **NATS KV 버킷**
|
|
28
|
+
(스케줄러 리스 `gaon_lease`·프레즌스·허브)에도 적용돼(결정 203) `test_gaon_lease`
|
|
29
|
+
처럼 격리된다 — 스트림뿐 아니라 KV 도 개발·운영 스택과 안 겹친다. 그래서
|
|
30
|
+
개발용 `gaon work` 가 떠 있어도 테스트 잡을 훔치지 않고 스케줄러 리더 경합도
|
|
31
|
+
안 생기며, 테스트가 남긴 잡을 개발 워커가 처리하지도 않는다 — **테스트 전에
|
|
32
|
+
워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이 격리가 없어 개발 스택과
|
|
33
|
+
섞이니 `gaon test` 를 쓴다.)
|
|
31
34
|
- SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
|
|
32
35
|
아니다.
|
|
33
36
|
|
|
@@ -256,5 +259,6 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
|
|
|
256
259
|
| 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
|
|
257
260
|
| 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
|
|
258
261
|
| 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
|
|
262
|
+
| 결정 203 | 같은 접두를 **NATS KV 버킷**에도 적용(`gaon_lease`·프레즌스·허브 → `test_gaon_lease` 등) — 스트림만 격리하던 결정 130 의 빈틈(KV 미격리)을 메움. 스케줄러 리스 경합·크로스-프리픽스 KV 누출 방지 |
|
|
259
263
|
| §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
|
|
260
264
|
| 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
|