@gaonjs/cli 0.55.0 → 0.57.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.
Files changed (33) hide show
  1. package/dist/commands/dev.js +22 -3
  2. package/dist/commands/test.js +28 -4
  3. package/dist/doctor/auth-wiring.js +5 -2
  4. package/dist/doctor/channel-collision.d.ts +9 -0
  5. package/dist/doctor/channel-collision.js +119 -0
  6. package/dist/doctor/dotenv-node-env.d.ts +5 -0
  7. package/dist/doctor/dotenv-node-env.js +68 -0
  8. package/dist/doctor/fixers/index.d.ts +1 -1
  9. package/dist/doctor/fixers/index.js +11 -1
  10. package/dist/doctor/types.d.ts +1 -1
  11. package/dist/doctor.d.ts +10 -2
  12. package/dist/doctor.js +50 -3
  13. package/dist/generate.d.ts +5 -0
  14. package/dist/generate.js +11 -3
  15. package/dist/index.js +7 -5
  16. package/dist/nodeEnv.d.ts +23 -0
  17. package/dist/nodeEnv.js +26 -0
  18. package/dist/scaffold/controller.js +3 -1
  19. package/dist/scaffold/page.js +6 -4
  20. package/dist/serve.js +13 -1
  21. package/dist/templates/project/AGENTS.md.tpl +34 -5
  22. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  23. package/dist/templates/project/agents/async.md.tpl +20 -7
  24. package/dist/templates/project/agents/data.md.tpl +65 -11
  25. package/dist/templates/project/agents/frontend.md.tpl +19 -9
  26. package/dist/templates/project/agents/i18n.md.tpl +17 -4
  27. package/dist/templates/project/agents/realtime.md.tpl +105 -15
  28. package/dist/templates/project/agents/seal.md.tpl +19 -7
  29. package/dist/templates/project/agents/security.md.tpl +13 -1
  30. package/dist/templates/project/agents/storage.md.tpl +21 -9
  31. package/dist/templates/project/agents/testing.md.tpl +58 -0
  32. package/dist/templates/project/agents/web.md.tpl +157 -15
  33. package/package.json +6 -6
@@ -30,6 +30,7 @@ import { existsSync } from 'node:fs';
30
30
  import { resolve } from 'node:path';
31
31
  import { loadDotEnv, VERSION } from '@gaonjs/core';
32
32
  import { registerTsResolve } from '../tsResolve.js';
33
+ import { defaultNodeEnv } from '../nodeEnv.js';
33
34
  import { startDev, resolveDevLayout } from '../dev.js';
34
35
  import { generateTablesDts, watchDir } from '@gaonjs/data';
35
36
  import { generateRoutesDts } from '@gaonjs/web';
@@ -84,7 +85,11 @@ function spawnServe(args) {
84
85
  // 셸에서 Ctrl+C 를 누르면 자식도 같이 SIGINT 를 받는데(같은 프로세스
85
86
  // 그룹), serve 는 자체 handler 로 graceful 종료하므로 안전.
86
87
  stdio: ['ignore', 'pipe', 'pipe'],
87
- env: { ...process.env },
88
+ // 결정 430: 부모의 **현재** env 가 아니라 dev 진입 시점 스냅샷을 준다. 부모 안에서
89
+ // 도는 라이브러리(vite build 는 이 프로세스에서 실행된다 · dev/build.ts)가 나중에
90
+ // process.env 를 만져도 자식이 흔들리지 않는다 — 최초 자식은 멀쩡한데 재기동된
91
+ // 자식만 오염되던 split-brain 을 구조로 제거한다.
92
+ env: args.env,
88
93
  });
89
94
  child.stdout?.on('data', (b) => args.console.pipe('serve', b, 'info'));
90
95
  child.stderr?.on('data', (b) => args.console.pipe('serve', b, 'error'));
@@ -104,7 +109,9 @@ function spawnProcess(args) {
104
109
  const child = spawn(node, [...nodeArgs, ...cmdArgs], {
105
110
  cwd: args.cwd,
106
111
  stdio: ['ignore', 'pipe', 'pipe'],
107
- env: { ...process.env, ...args.extraEnv },
112
+ // 결정 430: spawnServe 와 같은 스냅샷 규칙 — hub 는 재기동하지 않으므로, 부모 env
113
+ // 그때그때 읽으면 hub 와 재기동된 serve·work 의 NODE_ENV 가 갈렸다(split-brain).
114
+ env: { ...args.env, ...args.extraEnv },
108
115
  });
109
116
  child.stdout?.on('data', (b) => args.console.pipe(args.kind, b, 'info'));
110
117
  child.stderr?.on('data', (b) => args.console.pipe(args.kind, b, 'error'));
@@ -164,6 +171,15 @@ export async function runDevCommand(opts = {}) {
164
171
  const signals = opts.signals ?? process;
165
172
  registerTsResolve();
166
173
  loadDotEnv(cwd);
174
+ // 결정 430: 모드는 명령이 정한다 — `gaon dev` 는 development. **vite 기동보다 먼저**
175
+ // 잡아야 한다: 아래 startFrontendBuild 가 vite.build 를 이 프로세스 안에서 부르는데,
176
+ // vite 의 resolveConfig(build)가 NODE_ENV 미설정이면 process.env.NODE_ENV 에
177
+ // 'production' 을 꽂는다(vite 7 config.js `isNodeEnvSet` 분기 · 2026-08-06 실측).
178
+ // 그러면 파일 저장으로 재기동된 serve 자식이 production 을 상속해 스캐폴드 dev
179
+ // 플레이스홀더 secret 이 prod 세션 게이트(결정 255)에 걸려 즉사했다.
180
+ defaultNodeEnv('development');
181
+ // 자식(serve·work·hub)에 물려줄 env 스냅샷 — 여기서 고정한다(결정 430).
182
+ const childEnv = { ...process.env };
167
183
  const consoleOut = createDevConsole({ json, timestamp: opts.timestamp });
168
184
  const startedAt = Date.now();
169
185
  consoleOut.log('dev', `gaon dev · 시작 (v${VERSION}) — ${cwd}`);
@@ -231,6 +247,7 @@ export async function runDevCommand(opts = {}) {
231
247
  port: opts.port,
232
248
  host: opts.host,
233
249
  console: consoleOut,
250
+ env: childEnv,
234
251
  onExit: (code, signal) => {
235
252
  if (shuttingDown)
236
253
  return;
@@ -257,11 +274,12 @@ export async function runDevCommand(opts = {}) {
257
274
  cwd,
258
275
  json,
259
276
  console: consoleOut,
277
+ env: childEnv,
260
278
  // dev 워커는 잡을 병렬 처리한다(기본 1 → DEV_WORKER_CONCURRENCY). 헤드오브라인
261
279
  // 블로킹을 없애 개발 반복을 빠르게 하고, 분산 락(중첩 방지) 같은 동시성 속성이
262
280
  // 단일 dev 워커에서도 관측되게 한다. 사용자가 GAON_WORKER_CONCURRENCY 를 이미
263
281
  // 주면 그 값을 존중한다(override 하지 않음).
264
- extraEnv: process.env.GAON_WORKER_CONCURRENCY == null || process.env.GAON_WORKER_CONCURRENCY === ''
282
+ extraEnv: childEnv.GAON_WORKER_CONCURRENCY == null || childEnv.GAON_WORKER_CONCURRENCY === ''
265
283
  ? { GAON_WORKER_CONCURRENCY: String(DEV_WORKER_CONCURRENCY) }
266
284
  : undefined,
267
285
  onExit: (code, signal) => {
@@ -292,6 +310,7 @@ export async function runDevCommand(opts = {}) {
292
310
  cwd,
293
311
  json,
294
312
  console: consoleOut,
313
+ env: childEnv,
295
314
  onExit: (code, signal) => {
296
315
  if (shuttingDown)
297
316
  return;
@@ -94,8 +94,19 @@ async function provisionTestDatabases(cwd, json, onOutput) {
94
94
  if (dbs.length === 0)
95
95
  return true;
96
96
  const prepared = [];
97
+ // 결정 279·292: 문서형(mongodb)은 SQL 프로비저닝(CREATE DATABASE + 마이그레이션)이 없다 —
98
+ // 순회에서 건너뛰고 보고에만 남긴다. 이 skip 이 없으면 SQL 전용 deriveTestDatabaseConfig/
99
+ // ensureTestDatabaseExists 가 몽고 커넥션에 pg/mysql 드라이버로 붙으려다 throw 해,
100
+ // 몽고 커넥션이 하나라도 있는 프로젝트는 `gaon test` 가 테스트 실행 전에 즉사했다
101
+ // (testkit·`gaon db` 는 이미 건너뛰는데 CLI test 만 빠진 비대칭). 테스트 레인의 몽고
102
+ // 커넥션 자체는 connectTestDatabase(@gaonjs/config testkit)가 등록한다.
103
+ const skippedMongo = [];
97
104
  try {
98
105
  for (const [key, cfg] of dbs) {
106
+ if (cfg.adapter === 'mongodb') {
107
+ skippedMongo.push(key);
108
+ continue;
109
+ }
99
110
  const testCfg = deriveTestDatabaseConfig(cfg);
100
111
  await ensureTestDatabaseExists(testCfg);
101
112
  const res = await runDbMigrate({
@@ -130,10 +141,23 @@ async function provisionTestDatabases(cwd, json, onOutput) {
130
141
  finally {
131
142
  await destroyAllConnections();
132
143
  }
133
- if (json)
134
- writeOut(JSON.stringify({ kind: 'provisioned', dbs: prepared }) + '\n');
135
- else
136
- writeOut(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
144
+ if (json) {
145
+ writeOut(JSON.stringify({
146
+ kind: 'provisioned',
147
+ dbs: prepared,
148
+ skipped: skippedMongo.map((db) => ({ db, adapter: 'mongodb', reason: 'documental-no-migration' })),
149
+ }) + '\n');
150
+ }
151
+ else {
152
+ // SQL 커넥션이 하나도 없으면(문서형 전용 프로젝트) "준비 완료 ()" 대신 건너뜀만 알린다.
153
+ if (prepared.length > 0) {
154
+ writeOut(` gaon test · 테스트 DB 준비 완료 (${prepared.join(', ')}) — <db>_test\n`);
155
+ }
156
+ if (skippedMongo.length > 0) {
157
+ writeOut(` gaon test · 문서형(mongodb) 커넥션 건너뜀: ${skippedMongo.join(', ')} — 문서형은 마이그레이션이 없습니다(§7.4).\n` +
158
+ ` → 테스트 레인의 몽고 커넥션은 test/setup.ts 의 connectTestDatabase() 가 <db>_test 로 등록합니다(결정 279).\n`);
159
+ }
160
+ }
137
161
  return true;
138
162
  }
139
163
  /**
@@ -12,6 +12,7 @@ import { readdir, readFile } from 'node:fs/promises';
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join, relative } from 'node:path';
14
14
  import { stripComments, stripCommentsAndStrings } from './source-scan.js';
15
+ import { jwtSecretEnvFor } from '../generate.js';
15
16
  // 결정 140(12차 W1): 판정 전 주석/문자열을 지운다. `// session:` 주석이나 문자열
16
17
  // 안의 우연한 일치가 배선으로 오탐되면 보안 검사(세션·CSRF)가 통째로 skip 된다.
17
18
  /** 컨트롤러 소스가 인증 표면을 쓰는지 판정한다(단위 테스트 진입점). */
@@ -57,10 +58,12 @@ export async function checkAuthWiring(cwd) {
57
58
  file: authFiles[0],
58
59
  message: `인증 배선 누락: apps/${app} 컨트롤러(${authFiles.join(', ')})가 requireAuth/this.auth 를 쓰는데 ` +
59
60
  `${acRel} 에 auth 배선이 없습니다. 이대로면 로그인해도 currentUser 가 항상 null 입니다(결정 59).\n` +
60
- `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
61
+ `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요(세션 앱 · 정본):\n` +
61
62
  ` session: { secret: process.env.SESSION_SECRET ?? '32자 이상 비밀' },\n` +
62
63
  ` auth: { loadUser, loginRedirect: '/session/new' } // import { loadUser } from './auth.js'\n` +
63
- `→ loadUser 없으면 \`gaon g auth --app ${app}\` 스캐폴드가 apps/${app}/auth.ts 에 만들어 줍니다.`,
64
+ `→ API(JWT) 앱이라면 세션 대신 토큰 배선입니다(결정 337·338 세션·loginRedirect 없음):\n` +
65
+ ` auth: { strategy: 'jwt', secret: process.env.${jwtSecretEnvFor(app)} ?? '32자 이상 dev 비밀', loadUser }\n` +
66
+ `→ loadUser 가 없으면 \`gaon g auth --app ${app}\`(세션) 또는 \`gaon g auth --jwt --app ${app}\`(JWT) 스캐폴드가 apps/${app}/auth.ts 에 만들어 줍니다.`,
64
67
  detail: { app, controllers: authFiles },
65
68
  });
66
69
  }
@@ -0,0 +1,9 @@
1
+ import type { RuleReport } from './types.js';
2
+ /**
3
+ * 소스가 default 를 다른 모듈에서 그대로 재수출만 하는지 판정하고 그 모듈 지정자를 낸다.
4
+ * `export { default } from '...'` · `export { default as default } from '...'` ·
5
+ * `export * from '...'` 를 인정한다(주석 제외 후 판정).
6
+ */
7
+ export declare function reexportSpecifier(source: string): string | undefined;
8
+ /** apps/ 를 훑어 앱간 동명 채널을 낸다. */
9
+ export declare function checkChannelCollision(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,119 @@
1
+ // @gaonjs/cli · doctor · 앱간 동명 채널 검사 (§7 실시간)
2
+ //
3
+ // 채널 이름은 **전역 네임스페이스**다. 브로드캐스트 subject(`gaon.chan.<이름>`)와
4
+ // 프레즌스 KV 키(`presence.<이름>.<멤버>`) 어디에도 앱 프리픽스가 없다(async/keys.ts).
5
+ // 그래서 `apps/web/channels/room.ts` 와 `apps/admin/channels/room.ts` 처럼 두 앱이
6
+ // 같은 파일명(=채널명)으로 **각자** 채널을 정의하면:
7
+ // · 한쪽 앱에서 broadcast 한 메시지가 다른 앱 연결로도 팬아웃되고,
8
+ // · 접속자 목록(presence)이 두 앱 것으로 병합되며,
9
+ // · 두 정의의 authorize/presenceInfo 가 갈려 한쪽의 공개 규칙이 다른 앱의 비공개
10
+ // 메시지를 새게 한다(연결이 어느 앱 WS 로 붙었느냐로 훅이 갈리기 때문).
11
+ // 잡·리스너의 동명 등록은 런타임에 throw 로 막지만(결정 271), 채널은 앱별 맵에
12
+ // 따로 담겨 런타임 충돌 신호가 없다 — 그래서 정적 검사로 잡는다.
13
+ //
14
+ // 의도적 공유의 탈출구: 정의를 `shared/channels/<이름>.ts` 하나에 두고 각 앱 채널
15
+ // 파일이 **재수출**하면 통과한다(정의 하나 = 훅·인가 규칙 하나 · shared 는 앱을
16
+ // 모르므로 의존 방향 4규칙에도 맞다).
17
+ //
18
+ // 판정은 소스 텍스트 기반(가벼운 정적 검사) — 채널 모듈을 실행하지 않는다.
19
+ import { readdir, readFile } from 'node:fs/promises';
20
+ import { join, relative, resolve, dirname } from 'node:path';
21
+ import { stripComments } from './source-scan.js';
22
+ /**
23
+ * 소스가 default 를 다른 모듈에서 그대로 재수출만 하는지 판정하고 그 모듈 지정자를 낸다.
24
+ * `export { default } from '...'` · `export { default as default } from '...'` ·
25
+ * `export * from '...'` 를 인정한다(주석 제외 후 판정).
26
+ */
27
+ export function reexportSpecifier(source) {
28
+ const src = stripComments(source);
29
+ const named = src.match(/export\s*\{\s*default(?:\s+as\s+default)?\s*\}\s*from\s*['"]([^'"]+)['"]/);
30
+ if (named)
31
+ return named[1];
32
+ const star = src.match(/export\s*\*\s*from\s*['"]([^'"]+)['"]/);
33
+ if (star)
34
+ return star[1];
35
+ return undefined;
36
+ }
37
+ /** 모듈 지정자를 파일 기준 절대경로로 해석한다(ESM `.js` → 소스 `.ts` 정규화). */
38
+ function resolveSpecifier(fromFile, spec) {
39
+ if (!spec.startsWith('.'))
40
+ return undefined; // 패키지 지정자는 동일성 판정 불가.
41
+ const abs = resolve(dirname(fromFile), spec);
42
+ return abs.replace(/\.js$/, '.ts');
43
+ }
44
+ /** apps/ 를 훑어 앱간 동명 채널을 낸다. */
45
+ export async function checkChannelCollision(cwd) {
46
+ const appsDir = join(cwd, 'apps');
47
+ // 채널 이름 → 정의 파일들.
48
+ const byName = new Map();
49
+ for (const app of await safeListDirs(appsDir)) {
50
+ const chDir = join(appsDir, app, 'channels');
51
+ for (const file of await safeListFiles(chDir)) {
52
+ if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
53
+ continue;
54
+ const abs = join(chDir, file);
55
+ const name = file.slice(0, -3);
56
+ const spec = reexportSpecifier(await readFile(abs, 'utf8'));
57
+ const entry = {
58
+ app,
59
+ rel: relative(cwd, abs),
60
+ reexportTarget: spec ? resolveSpecifier(abs, spec) : undefined,
61
+ };
62
+ const list = byName.get(name);
63
+ if (list)
64
+ list.push(entry);
65
+ else
66
+ byName.set(name, [entry]);
67
+ }
68
+ }
69
+ const issues = [];
70
+ for (const [name, entries] of [...byName.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
71
+ if (entries.length < 2)
72
+ continue;
73
+ // readdir 순서에 보고가 흔들리지 않게 앱 이름으로 고정한다.
74
+ const files = [...entries].sort((a, b) => (a.app < b.app ? -1 : a.app > b.app ? 1 : 0));
75
+ // 전부 같은 모듈을 재수출하면 정의는 하나다 — 의도적 공유로 인정한다.
76
+ const targets = files.map((f) => f.reexportTarget);
77
+ if (targets.every((t) => t !== undefined && t === targets[0]))
78
+ continue;
79
+ const apps = files.map((f) => f.app).join(', ');
80
+ const list = files.map((f) => f.rel).join(' · ');
81
+ // 이름 변경을 권할 대상 — web 은 프리픽스 '/' 의 기본 앱이라 되도록 그대로 두고
82
+ // 비-web 앱 쪽 이름을 바꾸도록 제안한다(둘 다 비-web 이면 두 번째).
83
+ const target = files.find((f) => f.app !== 'web') ?? files[1];
84
+ const renamed = `${target.app}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
85
+ issues.push({
86
+ rule: 'channel-collision',
87
+ level: 'error',
88
+ file: files[0].rel,
89
+ message: `앱간 동명 채널 '${name}': ${apps} 앱이 각각 정의합니다(${list}).\n` +
90
+ `채널 이름은 전역입니다 — 브로드캐스트 subject(gaon.chan.${name})와 프레즌스 키에 앱 프리픽스가 ` +
91
+ `없어 두 앱의 연결이 같은 채널로 팬아웃되고 접속자 목록이 병합됩니다. 두 정의의 authorize 가 다르면 ` +
92
+ `한쪽의 공개 규칙으로 다른 앱 메시지가 샙니다.\n` +
93
+ `→ 앱마다 이름을 분리하세요: apps/${target.app}/channels/${renamed}.ts ` +
94
+ `(클라이언트 useChannel('${renamed}') 도 함께 바꿉니다).\n` +
95
+ `→ 일부러 공유하는 채널이면 정의를 shared/channels/${name}.ts 하나에 두고 각 앱 채널 파일에서 재수출하세요:\n` +
96
+ ` export { default } from '../../../shared/channels/${name}.js'`,
97
+ detail: { channel: name, apps: files.map((f) => f.app), files: files.map((f) => f.rel) },
98
+ });
99
+ }
100
+ return { rule: 'channel-collision', issues };
101
+ }
102
+ async function safeListDirs(dir) {
103
+ try {
104
+ const entries = await readdir(dir, { withFileTypes: true });
105
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ }
111
+ async function safeListFiles(dir) {
112
+ try {
113
+ const entries = await readdir(dir, { withFileTypes: true });
114
+ return entries.filter((e) => e.isFile()).map((e) => e.name);
115
+ }
116
+ catch {
117
+ return [];
118
+ }
119
+ }
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 한 줄이 NODE_ENV 대입이면 그 값을 낸다. 주석·export 접두·따옴표를 흡수한다. */
3
+ export declare function nodeEnvAssignment(line: string): string | undefined;
4
+ /** 공유 `.env` 파일에 박힌 NODE_ENV 를 경고한다. */
5
+ export declare function checkDotenvNodeEnv(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,68 @@
1
+ // @gaonjs/cli · doctor · `.env` 의 NODE_ENV 검사 (결정 430)
2
+ //
3
+ // **모드는 명령이 정한다** — `gaon dev` = development · `gaon serve` = production.
4
+ // `.env`(와 `.env.local`·`.env.example`)는 dev·prod·테스트가 **공유**하는 파일이라, 여기에
5
+ // NODE_ENV 를 박으면 그 값이 모든 실행 모드로 새어나간다:
6
+ // · `NODE_ENV=production` → `gaon dev` 가 production 으로 떠 세션 쿠키 Secure(비-TLS 로컬에서
7
+ // 로그인 불가)·dev 플레이스홀더 secret 거부(결정 255)로 부팅이 죽고, `gaon db reset` 이 거부된다.
8
+ // · `NODE_ENV=development` → 운영 배포가 development 로 떠 prod 게이트(플레이스홀더 secret 거부·
9
+ // 쿠키 Secure·락 in-memory 폴백 차단)가 통째로 안 걸린다. **부팅은 green, 보안만 꺼진다.**
10
+ // 어느 쪽이든 무신호 파손이라 정적 검사로 짚는다. fail-closed 는 아니다(warn) — 기존 프로젝트를
11
+ // 부팅 불가로 만들지 않고, 정당한 예외(컨테이너 이미지 안에서 `.env` 를 모드별로 생성하는 파이프라인)를
12
+ // 남긴다. 모드별 오버레이(`.env.development`·`.env.production`)는 이미 모드로 갈리므로 검사 대상이 아니다.
13
+ //
14
+ // 판정은 라인 텍스트 기반 — `.env` 는 셸 문법이 아니라 KEY=VALUE 목록이다(주석 `#` 제외).
15
+ import { readFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ /**
18
+ * 검사 대상 — **모드를 가리지 않는** 공유 env 파일만. `.env.development` 같은 오버레이는
19
+ * 이름 자체가 모드라 NODE_ENV 를 적어도 새지 않는다(core loadDotEnv 가 모드별로만 얹는다).
20
+ */
21
+ const SHARED_ENV_FILES = ['.env', '.env.local', '.env.example'];
22
+ /** 한 줄이 NODE_ENV 대입이면 그 값을 낸다. 주석·export 접두·따옴표를 흡수한다. */
23
+ export function nodeEnvAssignment(line) {
24
+ const trimmed = line.trim();
25
+ if (trimmed === '' || trimmed.startsWith('#'))
26
+ return undefined;
27
+ const m = trimmed.match(/^(?:export\s+)?NODE_ENV\s*=\s*(.*)$/);
28
+ if (!m)
29
+ return undefined;
30
+ // 값에서 인라인 주석·따옴표를 벗긴다(빈 값도 "설정됨" 으로 본다 — 빈 NODE_ENV 가
31
+ // vite 의 `!!process.env.NODE_ENV` 판정을 미설정으로 만들어 production 을 꽂는다).
32
+ const raw = m[1].replace(/\s+#.*$/, '').trim();
33
+ return raw.replace(/^['"]|['"]$/g, '');
34
+ }
35
+ /** 공유 `.env` 파일에 박힌 NODE_ENV 를 경고한다. */
36
+ export async function checkDotenvNodeEnv(cwd) {
37
+ const issues = [];
38
+ for (const name of SHARED_ENV_FILES) {
39
+ let text;
40
+ try {
41
+ text = await readFile(join(cwd, name), 'utf8');
42
+ }
43
+ catch {
44
+ continue; // 없으면 통과(대부분의 프로젝트에 .env.local 은 없다).
45
+ }
46
+ const lines = text.split(/\r?\n/);
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const value = nodeEnvAssignment(lines[i]);
49
+ if (value === undefined)
50
+ continue;
51
+ issues.push({
52
+ rule: 'dotenv-node-env',
53
+ level: 'warning',
54
+ file: name,
55
+ line: i + 1,
56
+ message: `${name} 에 NODE_ENV${value === '' ? '' : `=${value}`} 이(가) 설정돼 있습니다 — 이 파일은 개발·운영이 함께 읽습니다.\n` +
57
+ `모드는 **명령이 정합니다**: gaon dev = development · gaon serve = production(결정 430).\n` +
58
+ `여기에 값을 박으면 그 모드가 양쪽으로 샙니다 — production 이면 gaon dev 가 쿠키 Secure·` +
59
+ `dev secret 거부로 죽고, development 면 운영 serve 에서 프로덕션 안전장치(플레이스홀더 secret 거부·` +
60
+ `쿠키 Secure)가 통째로 꺼집니다(부팅은 green).\n` +
61
+ `→ ${name} 에서 NODE_ENV 줄을 지우세요. 특정 모드에서만 필요한 값은 .env.development / .env.production ` +
62
+ `오버레이에 두고, 일회성으로 바꿀 땐 명령 앞에 붙이세요: NODE_ENV=production gaon serve`,
63
+ detail: { file: name, line: i + 1, value },
64
+ });
65
+ }
66
+ }
67
+ return { rule: 'dotenv-node-env', issues };
68
+ }
@@ -14,7 +14,7 @@ export declare const FIXERS: Partial<Record<DoctorRule, Fixer>>;
14
14
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
15
15
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
16
16
  *
17
- * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 28종을 **빠짐없이** 담는다 —
17
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 30종을 **빠짐없이** 담는다 —
18
18
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
19
19
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
20
20
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -28,7 +28,7 @@ export const FIXERS = {
28
28
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
29
29
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
30
30
  *
31
- * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 28종을 **빠짐없이** 담는다 —
31
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 30종을 **빠짐없이** 담는다 —
32
32
  * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
33
33
  * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
34
34
  * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
@@ -175,4 +175,14 @@ export const FIXER_CAPABILITIES = [
175
175
  hasFixer: false,
176
176
  note: '수동 · 로케일 간 누락 키는 각 카탈로그(locales/*.json)에 채우세요 — 번역문은 사람이 작성합니다(결정 216).',
177
177
  },
178
+ {
179
+ rule: 'channel-collision',
180
+ hasFixer: false,
181
+ note: '수동 · 앱마다 채널 이름을 분리(파일명 + 클라이언트 useChannel 인자 동시 변경)하거나, 의도적 공유면 정의를 shared/channels/ 하나로 옮기고 각 앱에서 재수출하세요 — 어느 쪽인지는 설계 판단이라 자동 정정하지 않습니다.',
182
+ },
183
+ {
184
+ rule: 'dotenv-node-env',
185
+ hasFixer: false,
186
+ note: '수동 · 공유 .env 에서 NODE_ENV 줄을 지우세요 — 모드는 명령이 정합니다(gaon dev=development · gaon serve=production · 결정 430). 지웠을 때 어떤 모드로 돌리려던 것인지는 사람이 알아야 해서 자동 정정하지 않습니다.',
187
+ },
178
188
  ];
@@ -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';
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' | '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
@@ -24,17 +24,25 @@ export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActio
24
24
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
25
25
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
26
26
  export { checkLocaleParity } from './doctor/locale-parity.js';
27
+ export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
27
28
  export { renderHuman, renderJson } from './doctor/reporter.js';
28
29
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
29
30
  /**
30
- * 실행할 검사 이름. 지정 없음(undefined) = 28개 모두.
31
+ * 실행할 검사 이름. 지정 없음(undefined) = 30개 모두.
31
32
  */
32
33
  /**
33
- * doctor 정적 검사 28종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
34
+ * doctor 정적 검사 30종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
34
35
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
35
36
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
36
37
  */
37
38
  export declare const ALL_RULES: readonly DoctorRule[];
39
+ /**
40
+ * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
41
+ * `Record<DoctorRule, …>` 라 **규칙을 늘리면 여기도 채워야 컴파일된다** — 손유지
42
+ * 산문 열거가 뒤처져 새 검사가 help 에서 통째로 빠지던 표류를 컴파일 에러로 바꾼다
43
+ * (channel-collision 편입 때 실제로 한 곳이 누락됐다).
44
+ */
45
+ export declare const RULE_SUMMARIES: Record<DoctorRule, string>;
38
46
  export interface DoctorCommandOptions {
39
47
  readonly cwd?: string;
40
48
  readonly json?: boolean;
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
- * 28 검사를 조립한다:
4
+ * 30 검사를 조립한다:
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 규칙)
@@ -30,6 +30,8 @@
30
30
  * 26) no-import-meta-env (결정 198 · F-9 ② · `.vue` 의 import.meta.env = TS1470 → env 접근자 안내 error)
31
31
  * 27) locale-parity (결정 216 · 13차 W4 · 로케일 간 키 부분 누락 = fallback 조용 노출 경고)
32
32
  * 28) render-return (결정 340 · this.render/redirect/json 호출만 하고 return 누락 = 무신호 204 경고)
33
+ * 29) channel-collision (§7 · 앱간 동명 채널 = 전역 subject·프레즌스 병합 error)
34
+ * 30) dotenv-node-env (결정 430 · 공유 .env 의 NODE_ENV = 모드 누출 경고)
33
35
  *
34
36
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
35
37
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -70,6 +72,8 @@ import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
70
72
  import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
71
73
  import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
72
74
  import { checkLocaleParity } from './doctor/locale-parity.js';
75
+ import { checkChannelCollision } from './doctor/channel-collision.js';
76
+ import { checkDotenvNodeEnv } from './doctor/dotenv-node-env.js';
73
77
  import { renderHuman, renderJson } from './doctor/reporter.js';
74
78
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
75
79
  import { makeResult, } from './doctor/types.js';
@@ -97,13 +101,14 @@ export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActio
97
101
  export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-layout-breakpoint.js';
98
102
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
99
103
  export { checkLocaleParity } from './doctor/locale-parity.js';
104
+ export { checkChannelCollision, reexportSpecifier } from './doctor/channel-collision.js';
100
105
  export { renderHuman, renderJson } from './doctor/reporter.js';
101
106
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
102
107
  /**
103
- * 실행할 검사 이름. 지정 없음(undefined) = 28개 모두.
108
+ * 실행할 검사 이름. 지정 없음(undefined) = 30개 모두.
104
109
  */
105
110
  /**
106
- * doctor 정적 검사 28종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
111
+ * doctor 정적 검사 30종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
107
112
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
108
113
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
109
114
  */
@@ -136,7 +141,47 @@ export const ALL_RULES = [
136
141
  'no-import-meta-env',
137
142
  'locale-parity',
138
143
  'render-return',
144
+ 'channel-collision',
145
+ 'dotenv-node-env',
139
146
  ];
147
+ /**
148
+ * `gaon help` 이 doctor 한 줄에 요약할 규칙별 문구(§2.2 상세는 AGENTS). 타입이
149
+ * `Record<DoctorRule, …>` 라 **규칙을 늘리면 여기도 채워야 컴파일된다** — 손유지
150
+ * 산문 열거가 뒤처져 새 검사가 help 에서 통째로 빠지던 표류를 컴파일 에러로 바꾼다
151
+ * (channel-collision 편입 때 실제로 한 곳이 누락됐다).
152
+ */
153
+ export const RULE_SUMMARIES = {
154
+ 'response-mixing': '응답 혼용',
155
+ 'n-plus-one': 'N+1',
156
+ 'dependency-direction': '의존 방향',
157
+ connections: '커넥션',
158
+ 'migration-diff': '마이그 diff',
159
+ 'shared-purity': 'shared 순수',
160
+ 'no-auto-import': '자동 import',
161
+ 'schema-filename': '스키마 파일명',
162
+ 'agents-doc-index': 'AGENTS 색인',
163
+ 'column-casing': '컬럼 casing',
164
+ 'model-filename': '모델 파일명',
165
+ 'page-filename': '페이지 파일명',
166
+ 'auth-wiring': '인증 배선',
167
+ 'ui-kit-wiring': 'UI 킷 배선',
168
+ 'route-registration': '라우트 등록',
169
+ 'static-collision': '정적 충돌',
170
+ 'method-override': '_method',
171
+ 'csrf-wiring': 'CSRF 배선',
172
+ 'internal-anchor': '내부 앵커',
173
+ 'pageprops-destructure': 'pageProps 구조분해',
174
+ 'async-offload': '비동기 오프로드',
175
+ 'page-layout-breakpoint': '페이지 레이아웃 브레이크포인트',
176
+ 'link-button-nesting': 'Link>Button 중첩',
177
+ 'seal-security': 'seal 클라 배선·보안 역전',
178
+ 'schema-relations': '§4.5 관계',
179
+ 'no-import-meta-env': 'import.meta.env',
180
+ 'locale-parity': '로케일 커버리지',
181
+ 'render-return': 'render return 누락',
182
+ 'channel-collision': '앱간 동명 채널',
183
+ 'dotenv-node-env': '.env NODE_ENV',
184
+ };
140
185
  const CHECKERS = {
141
186
  'response-mixing': checkResponseMixing,
142
187
  'n-plus-one': checkNPlusOne,
@@ -166,6 +211,8 @@ const CHECKERS = {
166
211
  'no-import-meta-env': checkNoImportMetaEnv,
167
212
  'locale-parity': checkLocaleParity,
168
213
  'render-return': checkRenderReturn,
214
+ 'channel-collision': checkChannelCollision,
215
+ 'dotenv-node-env': checkDotenvNodeEnv,
169
216
  };
170
217
  /**
171
218
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
@@ -32,6 +32,11 @@ export interface ScaffoldResult {
32
32
  */
33
33
  readonly incomplete: boolean;
34
34
  }
35
+ /**
36
+ * 결정 338: 앱별 JWT secret 환경변수명 — '<APP>_JWT_SECRET' (세션 secret 관례와 대칭).
37
+ * doctor auth-wiring 의 JWT 수리 안내가 같은 이름을 제시해야 해 export 한다(단일 출처).
38
+ */
39
+ export declare function jwtSecretEnvFor(app: string): string;
35
40
  /**
36
41
  * 결정 155·142: `g auth --app <앱>` 이 앱별 세션 secret 환경변수를 .env·.env.example
37
42
  * 에도 시드한다. web 은 SESSION_SECRET 이 프로젝트 스캐폴드(.env.example.tpl)에 이미
package/dist/generate.js CHANGED
@@ -65,8 +65,11 @@ function devSessionSecretFor(app) {
65
65
  function envSecretPlaceholderFor(app) {
66
66
  return `change-me-to-a-32-char-${app}-session-secret!!`;
67
67
  }
68
- /** 결정 338: 앱별 JWT secret 환경변수명 — '<APP>_JWT_SECRET' (세션 secret 관례와 대칭). */
69
- function jwtSecretEnvFor(app) {
68
+ /**
69
+ * 결정 338: 앱별 JWT secret 환경변수명 — '<APP>_JWT_SECRET' (세션 secret 관례와 대칭).
70
+ * doctor auth-wiring 의 JWT 수리 안내가 같은 이름을 제시해야 해 export 한다(단일 출처).
71
+ */
72
+ export function jwtSecretEnvFor(app) {
70
73
  return `${app.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_JWT_SECRET`;
71
74
  }
72
75
  /** .env(.example)에 시드할 앱별 JWT secret 플레이스홀더(32자 이상 · 운영은 교체 — 결정 337 가드 대상). */
@@ -382,8 +385,13 @@ export function runGenerateAuthCommand(opts = {}) {
382
385
  const app = opts.app ?? 'web';
383
386
  // 결정 338: JWT 는 API 앱 전용(§7 · web 은 세션이 정본). 형식 불량은 즉시 수리 안내.
384
387
  if (opts.jwt && (opts.app === undefined || app === 'web')) {
388
+ // 결정 338: API(JWT) 앱은 이 명령 **하나**가 앱 폴더째 만든다(routes·컨트롤러·app.config).
389
+ // 이전 힌트는 `gaon g app api && gaon g auth --jwt --app api` 를 권했는데, 앞 명령이
390
+ // 만든 app.config.ts·Vue 프론트(pages/layouts) 때문에 뒤 명령이 JWT 배선을 자동 추가하지
391
+ // 못하고 incomplete → exit 1 로 끝났다(결정 361). API 앱에는 쓰지 않는 Vue 페이지까지
392
+ // 남는다 — 권하는 순서가 곧 실패 경로였던 셈이라 단독 실행을 정본으로 못박는다.
385
393
  process.stderr.write(` ✗ gaon g auth --jwt: JWT 는 API 앱 전용입니다 — 대상 API 앱을 --app 으로 지정하세요(web 불가 · 세션이 정본).\n` +
386
- ` → 예: gaon g app api && gaon g auth --jwt --app api\n`);
394
+ ` → 예: gaon g auth --jwt --app api (이 명령 하나가 apps/api/ 를 통째로 만듭니다 — gaon g app 먼저 돌리지 마세요)\n`);
387
395
  return 1;
388
396
  }
389
397
  if (opts.jwt && opts.public) {
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import { runHubCommand } from "./hub.js";
27
27
  import { runWorkCommand } from "./work.js";
28
28
  import { runJobsCommand } from "./jobs.js";
29
29
  import { runDbCommand } from "./commands/db.js";
30
- import { runDoctorCommand, ALL_RULES } from "./doctor.js";
30
+ import { runDoctorCommand, ALL_RULES, RULE_SUMMARIES } from "./doctor.js";
31
31
  import { runMcpCommand } from "./commands/mcp.js";
32
32
  export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
33
33
  export { runDevCommand } from "./commands/dev.js";
@@ -111,7 +111,9 @@ function renderHelp(version = VERSION) {
111
111
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
112
112
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all)",
113
113
  " gaon test -- <인자> `--` 뒤는 vitest 로 그대로 전달 (gaon 이 안 가로챔 · 예: gaon test -- --json --reporter=json)",
114
- ` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지·render return 누락)`,
114
+ // 열거는 RULE_SUMMARIES(doctor.ts) 단일 출처로 쓴다 손유지 산문이 뒤처져
115
+ // 새 검사가 help 에서 빠지던 표류 방지(타입이 전수 채움을 강제).
116
+ ` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · ${ALL_RULES.map((r) => RULE_SUMMARIES[r]).join("·")})`,
115
117
  " gaon doctor --json 자동화용 JSON 출력",
116
118
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
117
119
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -149,7 +151,7 @@ function renderHelp(version = VERSION) {
149
151
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
150
152
  */
151
153
  export function parseDoctorChecks(argv) {
152
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 28종)를 단일 출처로 쓴다 — 과거
154
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 30종)를 단일 출처로 쓴다 — 과거
153
155
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
154
156
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
155
157
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -170,7 +172,7 @@ export function parseDoctorChecks(argv) {
170
172
  }
171
173
  // 결정 411: 모르는 이름은 여전히 무시하되(안전 방향 — 전체 검사로 넓어짐) **조용히**
172
174
  // 넘기지 않는다. 오타 하나가 "그 검사만 돌렸다" 는 착각으로 이어지고, 전부 오타면
173
- // 28종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
175
+ // 30종 전체가 돌아가 선택 실행 의도가 통째로 사라진다.
174
176
  if (unknown.length > 0) {
175
177
  process.stderr.write(` ! 알 수 없는 검사 이름 무시: ${unknown.join(", ")}\n` +
176
178
  ` → 지원 이름은 gaon doctor --json 의 rule 값 또는 gaon help 참고` +
@@ -470,7 +472,7 @@ export function runCli(argv, opts = {}) {
470
472
  });
471
473
  return;
472
474
  }
473
- // `gaon doctor` — 정적 검사(M9-E · 28 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
475
+ // `gaon doctor` — 정적 검사(M9-E · 30 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
474
476
  // 선택 실행, --json 은 자동화 파싱용.
475
477
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
476
478
  if (argv[0] === "doctor") {