@gaonjs/cli 0.43.0 → 0.52.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 (72) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/check.d.ts +1 -1
  3. package/dist/commands/check.js +1 -1
  4. package/dist/commands/db.js +29 -7
  5. package/dist/commands/new.d.ts +2 -0
  6. package/dist/commands/new.js +8 -3
  7. package/dist/commands/test.js +16 -3
  8. package/dist/db/journal.d.ts +4 -3
  9. package/dist/db/journal.js +21 -10
  10. package/dist/db/migrate.d.ts +3 -1
  11. package/dist/db/migrate.js +3 -3
  12. package/dist/db/replay.js +2 -2
  13. package/dist/db/resolve.d.ts +15 -0
  14. package/dist/db/resolve.js +24 -2
  15. package/dist/db/status.js +13 -3
  16. package/dist/dev.d.ts +6 -4
  17. package/dist/dev.js +9 -4
  18. package/dist/doctor/fixers/index.d.ts +1 -1
  19. package/dist/doctor/fixers/index.js +6 -1
  20. package/dist/doctor/locale-parity.js +4 -1
  21. package/dist/doctor/pageprops-destructure.d.ts +2 -2
  22. package/dist/doctor/pageprops-destructure.js +29 -23
  23. package/dist/doctor/render-return.d.ts +11 -0
  24. package/dist/doctor/render-return.js +143 -0
  25. package/dist/doctor/types.d.ts +1 -1
  26. package/dist/doctor.d.ts +3 -2
  27. package/dist/doctor.js +17 -6
  28. package/dist/generate.d.ts +20 -1
  29. package/dist/generate.js +120 -21
  30. package/dist/hub.js +2 -0
  31. package/dist/i18n-config.d.ts +12 -0
  32. package/dist/i18n-config.js +95 -0
  33. package/dist/index.d.ts +6 -0
  34. package/dist/index.js +59 -14
  35. package/dist/mcp/tools.d.ts +1 -1
  36. package/dist/mcp/tools.js +9 -6
  37. package/dist/messages-gen.d.ts +1 -1
  38. package/dist/messages-gen.js +5 -2
  39. package/dist/scaffold/job.js +3 -1
  40. package/dist/templates/auth/Dashboard.vue.tpl +3 -2
  41. package/dist/templates/auth/Login.vue.tpl +3 -5
  42. package/dist/templates/auth/Signup.vue.tpl +3 -5
  43. package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
  44. package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
  45. package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
  46. package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
  47. package/dist/templates/project/.dockerignore.tpl +3 -0
  48. package/dist/templates/project/.env.example.tpl +1 -1
  49. package/dist/templates/project/AGENTS.md.tpl +5 -3
  50. package/dist/templates/project/CLAUDE.md.tpl +5 -4
  51. package/dist/templates/project/Dockerfile.tpl +6 -1
  52. package/dist/templates/project/agents/async.md.tpl +75 -10
  53. package/dist/templates/project/agents/data.md.tpl +159 -22
  54. package/dist/templates/project/agents/frontend.md.tpl +51 -12
  55. package/dist/templates/project/agents/i18n.md.tpl +5 -2
  56. package/dist/templates/project/agents/mail.md.tpl +4 -2
  57. package/dist/templates/project/agents/realtime.md.tpl +37 -3
  58. package/dist/templates/project/agents/seal.md.tpl +14 -3
  59. package/dist/templates/project/agents/security.md.tpl +47 -11
  60. package/dist/templates/project/agents/storage.md.tpl +16 -8
  61. package/dist/templates/project/agents/web.md.tpl +78 -24
  62. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
  63. package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
  64. package/dist/templates/project/apps/web/main.ts.tpl +1 -1
  65. package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
  66. package/dist/templates/project/docker-compose.yaml.tpl +1 -1
  67. package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
  68. package/dist/templates/project/vite.config.ts.tpl +1 -1
  69. package/dist/work.d.ts +21 -0
  70. package/dist/work.js +45 -1
  71. package/package.json +12 -7
  72. package/dist/templates/index.ts +0 -109
package/dist/generate.js CHANGED
@@ -65,6 +65,14 @@ 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) {
70
+ return `${app.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_JWT_SECRET`;
71
+ }
72
+ /** .env(.example)에 시드할 앱별 JWT secret 플레이스홀더(32자 이상 · 운영은 교체 — 결정 337 가드 대상). */
73
+ function envJwtPlaceholderFor(app) {
74
+ return `change-me-to-a-32-char-${app}-jwt-secret!!!`;
75
+ }
68
76
  /**
69
77
  * 결정 155·142: `g auth --app <앱>` 이 앱별 세션 secret 환경변수를 .env·.env.example
70
78
  * 에도 시드한다. web 은 SESSION_SECRET 이 프로젝트 스캐폴드(.env.example.tpl)에 이미
@@ -77,8 +85,13 @@ function envSecretPlaceholderFor(app) {
77
85
  * .env 를 새로 만드는 관례가 없어(사용자가 cp .env.example .env), 존재하는 파일만 패치한다.
78
86
  * 반환: 실제로 키를 추가한 상대 경로들.
79
87
  */
80
- export function patchEnvFiles(root, app) {
81
- const secretEnv = sessionSecretEnvFor(app);
88
+ export function patchEnvFiles(root, app, kind = 'session') {
89
+ // 결정 338: JWT 변형은 세션 secret 대신 <APP>_JWT_SECRET 을 시드한다(동일 멱등 규칙).
90
+ const secretEnv = kind === 'jwt' ? jwtSecretEnvFor(app) : sessionSecretEnvFor(app);
91
+ const comment = kind === 'jwt'
92
+ ? `# ${app} 앱 JWT secret (32자 이상 · 앱별 분리 · 운영은 반드시 교체 — dev 폴백은 부팅 거부 · 결정 337).`
93
+ : `# ${app} 앱 세션 secret (32자 이상 · 앱별 세션 완전 분리 · 운영은 반드시 교체).`;
94
+ const placeholder = kind === 'jwt' ? envJwtPlaceholderFor(app) : envSecretPlaceholderFor(app);
82
95
  const patched = [];
83
96
  for (const rel of ['.env', '.env.example']) {
84
97
  const abs = join(root, rel);
@@ -88,8 +101,7 @@ export function patchEnvFiles(root, app) {
88
101
  // 라인 시작에서 `KEY=` 를 찾는다(주석·부분 일치 회피).
89
102
  if (new RegExp(`^${secretEnv}=`, 'm').test(existing))
90
103
  continue;
91
- const block = `\n# ${app} 앱 세션 secret (32자 이상 · 앱별 세션 완전 분리 · 운영은 반드시 교체).\n` +
92
- `${secretEnv}=${envSecretPlaceholderFor(app)}\n`;
104
+ const block = `\n${comment}\n${secretEnv}=${placeholder}\n`;
93
105
  const sep = existing.endsWith('\n') ? '' : '\n';
94
106
  writeFileSync(abs, existing + sep + block, 'utf8');
95
107
  patched.push(rel);
@@ -113,6 +125,7 @@ function renderTemplate(name, app, includePublic) {
113
125
  .replaceAll('{{APP_NAME}}', app)
114
126
  .replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
115
127
  .replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app))
128
+ .replaceAll('{{JWT_SECRET_ENV}}', jwtSecretEnvFor(app))
116
129
  // 결정 218: 시큐어 세션 컨트롤러의 로그인 역할 게이트 기준. 결정 155·145 의 대시보드
117
130
  // 역할 게이트(role === 'admin')와 같은 값으로 고정해 두 층이 어긋나지 않게 한다.
118
131
  .replaceAll('{{REQUIRED_ROLE}}', 'admin')
@@ -123,6 +136,18 @@ function renderTemplate(name, app, includePublic) {
123
136
  export function authScaffoldFiles(opts = {}) {
124
137
  const app = opts.app ?? 'web';
125
138
  const includePublic = includesPublicRegistration(opts);
139
+ // 결정 338: JWT(API 앱) 변형 — 페이지·UI 킷·회원가입 없이 스키마/모델(공유) +
140
+ // 토큰 컨트롤러(JSON 전용) + auth.strategy:'jwt' 배선만. API 앱은 프론트가 없다.
141
+ if (opts.jwt) {
142
+ const specs = [
143
+ { tpl: 'user.schema.ts.tpl', out: 'domain/schema/users.ts' },
144
+ { tpl: 'user.model.ts.tpl', out: 'domain/models/User.ts' },
145
+ { tpl: 'jwt.auth.wiring.ts.tpl', out: `apps/${app}/auth.ts` },
146
+ { tpl: 'jwt.session.controller.ts.tpl', out: `apps/${app}/controllers/session.ts` },
147
+ { tpl: 'jwt.app.config.ts.tpl', out: `apps/${app}/app.config.ts` },
148
+ ];
149
+ return specs.map(({ tpl, out }) => ({ path: out, contents: renderTemplate(tpl, app, false) }));
150
+ }
126
151
  const specs = [
127
152
  { tpl: 'user.schema.ts.tpl', out: 'domain/schema/users.ts' },
128
153
  { tpl: 'user.model.ts.tpl', out: 'domain/models/User.ts' },
@@ -157,6 +182,22 @@ export function authScaffoldFiles(opts = {}) {
157
182
  * 기존 routes.ts 에 세션·회원가입 리소스를 끼워 넣는다. 이미 있으면 null.
158
183
  * `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
159
184
  */
185
+ /**
186
+ * 결정 338: 기존 routes.ts 에 JWT 토큰 라우트 3종을 끼워 넣는다. 이미 session#create
187
+ * 참조가 있으면 null(중복 방지). 삽입 지점 규칙은 patchRoutes 와 동일.
188
+ */
189
+ export function patchRoutesJwt(existing) {
190
+ if (existing.includes("'session#create'") || existing.includes("resource('session')"))
191
+ return null;
192
+ const m = existing.match(/routes\(\s*\(\s*\w+\s*\)\s*=>\s*\{/);
193
+ if (!m || m.index === undefined)
194
+ return null;
195
+ const insertAt = m.index + m[0].length;
196
+ const inject = "\n r.post('/session', 'session#create') // 로그인 → 토큰 발급 (gaon g auth --jwt)" +
197
+ "\n r.post('/session/refresh', 'session#refresh') // 액세스 토큰 재발급 (gaon g auth --jwt)" +
198
+ "\n r.get('/session', 'session#show') // 현재 사용자 · Bearer (gaon g auth --jwt)";
199
+ return existing.slice(0, insertAt) + inject + existing.slice(insertAt);
200
+ }
160
201
  export function patchRoutes(existing, includePublic = true) {
161
202
  if (existing.includes("resource('session')"))
162
203
  return null;
@@ -225,17 +266,35 @@ export function writeAuthScaffold(cwd, opts = {}) {
225
266
  const skipped = [];
226
267
  const patched = [];
227
268
  const warnings = [];
269
+ let incomplete = false;
228
270
  // 결정 75: auth 페이지(Login/Signup/Dashboard)는 UI 킷 컴포넌트를 쓴다.
229
271
  // 필요한 최소 세트를 먼저 보장한다 — 이미 있으면(gaon g ui-kit 를 먼저 돌린
230
272
  // 경우) skip, 없으면 생성. 이 보장이 없으면 스캐폴드 직후 페이지가 컴포넌트를
231
273
  // 해상하지 못해 vue-tsc 가 깨진다(gaon new → g auth 단독 경로 · 결정 60 게이트).
232
274
  const includePublic = includesPublicRegistration(opts);
233
- const ui = writeUiKitFiles(root, authUiKitFiles(app));
234
- created.push(...ui.created);
235
- skipped.push(...ui.skipped);
275
+ // 결정 338: JWT 변형은 프론트가 없다(API 앱 · JSON 전용) — UI 킷을 깔지 않는다.
276
+ if (!opts.jwt) {
277
+ const ui = writeUiKitFiles(root, authUiKitFiles(app));
278
+ created.push(...ui.created);
279
+ skipped.push(...ui.skipped);
280
+ }
236
281
  for (const file of authScaffoldFiles(opts)) {
237
282
  const abs = join(root, file.path);
238
283
  if (existsSync(abs)) {
284
+ // 결정 338: JWT 변형의 app.config 자동 패치는 지원하지 않는다(세션 병존 등
285
+ // 판단이 필요) — 이미 있으면 skip + 수리 안내로 폴백한다.
286
+ if (opts.jwt && file.path === `apps/${app}/app.config.ts`) {
287
+ const existing = readFileSync(abs, 'utf8');
288
+ skipped.push(file.path);
289
+ if (!/\bauth\s*:/.test(existing)) {
290
+ incomplete = true;
291
+ warnings.push(`apps/${app}/app.config.ts 가 이미 있어 JWT 인증 배선을 자동 추가하지 못했습니다.\n` +
292
+ `→ defineAppConfig({...}) 에 다음을 추가하세요 (import { loadUser } from './auth.js'):\n` +
293
+ ` auth: { strategy: 'jwt', secret: process.env.${jwtSecretEnvFor(app)} ?? '32자 이상 dev 비밀', loadUser },\n` +
294
+ `→ secret 은 32자 이상 · 운영은 .env 의 ${jwtSecretEnvFor(app)} 로 주입해야 부팅합니다(결정 337).`);
295
+ }
296
+ continue;
297
+ }
239
298
  // 결정 93: gaon new 기본 web 앱은 app.config.ts 에 세션만 배선돼 있다.
240
299
  // g auth 는 이를 skip 하지 않고 **auth 배선을 패치로 추가**해 완성한다 —
241
300
  // 결정 59: auth 배선이 없으면 로그인 후에도 currentUser 가 영구 null 이다.
@@ -249,6 +308,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
249
308
  else if (!/\bauth\s*:/.test(existing)) {
250
309
  // 패치 불가(비관례 config) — skip + 수리 안내로 폴백.
251
310
  skipped.push(file.path);
311
+ incomplete = true;
252
312
  warnings.push(`apps/${app}/app.config.ts 가 이미 있어 인증 배선을 자동 추가하지 못했습니다.\n` +
253
313
  `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
254
314
  ` session: { secret: process.env.${sessionSecretEnvFor(app)} ?? '32자 이상 비밀' },\n` +
@@ -268,31 +328,45 @@ export function writeAuthScaffold(cwd, opts = {}) {
268
328
  writeFileSync(abs, file.contents, 'utf8');
269
329
  created.push(file.path);
270
330
  }
271
- // routes.ts — 있으면 패치, 없으면 템플릿에서 생성.
331
+ // routes.ts — 있으면 패치, 없으면 템플릿에서 생성. JWT 변형은 토큰 라우트 3종(결정 338).
272
332
  const routesPath = join(root, 'apps', app, 'routes.ts');
273
333
  const routesRel = `apps/${app}/routes.ts`;
274
334
  if (existsSync(routesPath)) {
275
- const patchedContent = patchRoutes(readFileSync(routesPath, 'utf8'), includePublic);
335
+ const existing = readFileSync(routesPath, 'utf8');
336
+ const patchedContent = opts.jwt ? patchRoutesJwt(existing) : patchRoutes(existing, includePublic);
276
337
  if (patchedContent) {
277
338
  writeFileSync(routesPath, patchedContent, 'utf8');
278
339
  patched.push(routesRel);
279
340
  }
341
+ else if (existing.includes("resource('session')") || existing.includes("'session#create'")) {
342
+ skipped.push(routesRel); // 이미 배선 — 정상 멱등.
343
+ }
280
344
  else {
345
+ // 결정 361: routes( ... ) 골격을 못 찾아 패치 불가 — 인증 라우트가 등록되지 않는다.
281
346
  skipped.push(routesRel);
347
+ incomplete = true;
348
+ warnings.push(`apps/${app}/routes.ts 가 관례 골격(routes((r) => { ... }))이 아니어서 인증 라우트를 자동 추가하지 못했습니다.\n` +
349
+ `→ apps/${app}/routes.ts 에 ${opts.jwt ? "r.post('/session', 'session#create') 등 토큰 라우트 3종" : "r.resource('session') 및 r.get('/dashboard', 'dashboard#show')"} 를 직접 추가하세요.`);
282
350
  }
283
351
  }
284
352
  else {
285
353
  mkdirSync(dirname(routesPath), { recursive: true });
286
- writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app, includePublic), 'utf8');
354
+ writeFileSync(routesPath, renderTemplate(opts.jwt ? 'jwt.routes.ts.tpl' : 'routes.ts.tpl', app, includePublic), 'utf8');
287
355
  created.push(routesRel);
288
356
  }
289
- // 결정 155·142(W3): 앱별 세션 secret 을 .env·.env.example 에 시드한다. web 은 이미
290
- // 있어 멱등, 비-web 앱은 여기서 키가 심어져 "공개 고정 dev secret 폴백" 표면을 막는다.
291
- patched.push(...patchEnvFiles(root, app));
357
+ // 결정 155·142(W3)·338: 앱별 secret 을 .env·.env.example 에 시드한다(세션 또는 JWT).
358
+ // web 은 이미 있어 멱등, 비-web 앱은 여기서 키가 심어져 dev 폴백 표면을 드러낸다.
359
+ patched.push(...patchEnvFiles(root, app, opts.jwt ? 'jwt' : 'session'));
360
+ // 결정 338: JWT 변형의 stateless 한계를 스캐폴드 시점에 명시한다(§7.5.3).
361
+ if (opts.jwt) {
362
+ warnings.push(`JWT 토큰은 stateless 입니다 — 서버측 폐기(로그아웃·강제 무효화) 수단이 없습니다(결정 337).\n` +
363
+ `→ 유출된 리프레시 토큰은 만료(기본 7d)까지 유효합니다. 민감한 앱은 app.config 의 refreshTtl 을 짧게 잡으세요.\n` +
364
+ `→ 사용자 계정은 이 API 앱에 회원가입이 없습니다 — web 앱 가입(gaon g auth) 또는 seed 로 만드세요.`);
365
+ }
292
366
  // 결정 155·218: 시큐어 앱(공개 가입 미생성)은 로그인 시점 역할 게이트 세션 컨트롤러 +
293
367
  // 역할 게이트 대시보드를 깔았다 — 두 층 다 role 컬럼이 있어야 동작하므로 추가를 안내한다
294
368
  // (§7.5.3 · authorize·로그인 게이트 예시가 실제 역할 규칙이 되도록).
295
- if (!includePublic) {
369
+ if (!includePublic && !opts.jwt) {
296
370
  warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155·218).\n` +
297
371
  `→ 관리자는 직접 만들거나 승격하세요(공개 가입 라우트 없음). 공개 가입이 필요하면 --public 로 다시 생성.\n` +
298
372
  `→ 역할 인가를 완성하려면 domain/schema/users.ts 에 role 컬럼을 추가하세요:\n` +
@@ -300,20 +374,33 @@ export function writeAuthScaffold(cwd, opts = {}) {
300
374
  ` 그러면 로그인(controllers/session.ts)이 역할 부족 시 세션을 만들지 않고,\n` +
301
375
  ` apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트도 실제 역할로 동작합니다.`);
302
376
  }
303
- return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings };
377
+ return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings, incomplete };
304
378
  }
305
379
  /** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
306
380
  export function runGenerateAuthCommand(opts = {}) {
307
381
  const cwd = opts.cwd ?? process.cwd();
308
382
  const app = opts.app ?? 'web';
309
- const result = writeAuthScaffold(cwd, { app, public: opts.public });
383
+ // 결정 338: JWT 는 API 앱 전용(§7 · web 세션이 정본). 형식 불량은 즉시 수리 안내.
384
+ if (opts.jwt && (opts.app === undefined || app === 'web')) {
385
+ 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`);
387
+ return 1;
388
+ }
389
+ if (opts.jwt && opts.public) {
390
+ process.stderr.write(` ✗ gaon g auth --jwt: --public 은 세션 스캐폴드 전용입니다 — JWT 변형은 회원가입을 깔지 않습니다(계정은 web 가입 또는 seed).\n`);
391
+ return 1;
392
+ }
393
+ const result = writeAuthScaffold(cwd, { app, public: opts.public, jwt: opts.jwt });
394
+ // 결정 361: 배선 미완(incomplete)은 성공이 아니다 — 이전엔 무조건 exit 0 이라
395
+ // 자동화가 "currentUser 영구 null" 스캐폴드를 성공으로 처리했다(g controller 의
396
+ // skip=1 과도 비대칭). JSON 은 ok 필드로, 종료 코드는 1 로 신호한다.
310
397
  if (opts.json) {
311
- process.stdout.write(JSON.stringify({ command: 'g auth', app, public: opts.public ?? app === 'web', ...result }, null, 2) + '\n');
312
- return 0;
398
+ process.stdout.write(JSON.stringify({ command: 'g auth', ok: !result.incomplete, app, jwt: opts.jwt ?? false, public: opts.jwt ? false : (opts.public ?? app === 'web'), ...result }, null, 2) + '\n');
399
+ return result.incomplete ? 1 : 0;
313
400
  }
314
- const includePublic = includesPublicRegistration({ app, public: opts.public });
401
+ const includePublic = !opts.jwt && includesPublicRegistration({ app, public: opts.public });
315
402
  const lines = [''];
316
- lines.push(` gaon g auth — 인증 스캐폴드 (${app} )`);
403
+ lines.push(` gaon g auth — 인증 스캐폴드 (${app} 앱${opts.jwt ? ' · JWT/API 변형' : ''})`);
317
404
  lines.push('');
318
405
  for (const f of result.created)
319
406
  lines.push(` + ${f}`);
@@ -327,6 +414,18 @@ export function runGenerateAuthCommand(opts = {}) {
327
414
  lines.push('', ` ⚠ ${w.split('\n').join('\n ')}`);
328
415
  lines.push('');
329
416
  const prefix = urlPrefixFor(app);
417
+ // 결정 338: JWT 변형은 다음 단계가 토큰 흐름이다(세션·Redis 불요 — 토큰 stateless).
418
+ if (opts.jwt) {
419
+ const jwtEnv = jwtSecretEnvFor(app);
420
+ lines.push(' 다음 단계:');
421
+ lines.push(` 1) .env 에 ${jwtEnv}(32자 이상 무작위)를 설정한다 — 운영은 dev 폴백이면 부팅 거부(결정 337).`);
422
+ lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
423
+ lines.push(' 3) gaon dev 로 실행한다 (.gaon 타입 브리지 생성 + JWT 인증 자동 배선).');
424
+ lines.push(` → POST ${prefix}/session 로그인(토큰 발급) · POST ${prefix}/session/refresh 재발급 · GET ${prefix}/session 현재 사용자(Bearer).`);
425
+ lines.push('');
426
+ process.stdout.write(lines.join('\n') + '\n');
427
+ return result.incomplete ? 1 : 0; // 결정 361
428
+ }
330
429
  const secretEnv = sessionSecretEnvFor(app);
331
430
  lines.push(' 다음 단계:');
332
431
  lines.push(` 1) .env 에 REDIS_URL·${secretEnv}(32자 이상)·COOKIE_SECRET 을 설정한다.`);
@@ -345,5 +444,5 @@ export function runGenerateAuthCommand(opts = {}) {
345
444
  }
346
445
  lines.push('');
347
446
  process.stdout.write(lines.join('\n') + '\n');
348
- return 0;
447
+ return result.incomplete ? 1 : 0; // 결정 361
349
448
  }
package/dist/hub.js CHANGED
@@ -62,6 +62,8 @@ export async function runHubCommand(opts = {}) {
62
62
  pingTimeoutMs: opts.pingTimeoutMs ?? envInt('GAON_HUB_PING_TIMEOUT_MS'),
63
63
  sweepMs: opts.sweepMs ?? envInt('GAON_HUB_SWEEP_MS'),
64
64
  reclaimGraceMs: envInt('GAON_HUB_RECLAIM_GRACE_MS'),
65
+ // 결정 350: 공유 토큰(선택). 설정 시 웹서버(serve)도 같은 GAON_HUB_TOKEN 필요.
66
+ authToken: process.env.GAON_HUB_TOKEN,
65
67
  onState: (s) => emit({ kind: 'leader', leader: s.leader, id }),
66
68
  });
67
69
  }
@@ -0,0 +1,12 @@
1
+ export interface ConfigI18nAnalysis {
2
+ /** defineConfig 인자에 i18n 프로퍼티가 존재하는가. */
3
+ readonly declared: boolean;
4
+ /** 정적 리터럴로 읽힌 dir(상대 경로 원문). 못 읽으면 undefined. */
5
+ readonly dir?: string;
6
+ /** 정적 리터럴로 읽힌 fallbackLng. 못 읽으면 undefined. */
7
+ readonly fallbackLng?: string;
8
+ }
9
+ /** gaon.config.ts 소스에서 i18n 블록의 dir·fallbackLng 리터럴을 뽑는다. */
10
+ export declare function analyzeConfigI18n(source: string): ConfigI18nAnalysis;
11
+ /** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
12
+ export declare function analyzeProjectI18n(cwd: string): ConfigI18nAnalysis;
@@ -0,0 +1,95 @@
1
+ // @gaonjs/cli · gaon.config.ts 의 i18n 블록 정적 분석 (결정 352)
2
+ //
3
+ // messages.d.ts 축(생성기·워처)과 doctor(locale-parity)는 런타임 config 로드 없이
4
+ // 돌아야 한다(.env 부재·부분 프로젝트에서도) — connections.ts(결정 135)와 같은
5
+ // 정적 AST 분석으로 `i18n.dir`(카탈로그 위치)·`i18n.fallbackLng`(기준 로케일)를
6
+ // 뽑는다. 이전엔 dir 이 'locales' 로 하드코딩돼 커스텀 dir 프로젝트에서 타입 축과
7
+ // doctor 가 무소음으로 죽었고, 기준 로케일은 fallbackLng 가 아니라 알파벳순 첫
8
+ // 로케일이었다(컴파일 보증이 fallback 체인과 다른 로케일에 정박).
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import ts from 'typescript';
12
+ /** gaon.config.ts 소스에서 i18n 블록의 dir·fallbackLng 리터럴을 뽑는다. */
13
+ export function analyzeConfigI18n(source) {
14
+ const sf = ts.createSourceFile('gaon.config.ts', source, ts.ScriptTarget.ES2022, true);
15
+ let declared = false;
16
+ let dir;
17
+ let fallbackLng;
18
+ // i18n 초기화식에서 객체 리터럴을 찾는다 — 삼항·??·&&·괄호는 양변을 훑는다
19
+ // (connections.ts 와 동일 규약 · env 조건부 블록 대응). 두 분기가 서로 다른
20
+ // 리터럴을 주는 병리적 케이스는 먼저 읽힌 값을 쓴다(실사용 형태 아님).
21
+ const collect = (node) => {
22
+ if (ts.isParenthesizedExpression(node))
23
+ return collect(node.expression);
24
+ if (ts.isObjectLiteralExpression(node)) {
25
+ for (const p of node.properties) {
26
+ if (!ts.isPropertyAssignment(p))
27
+ continue;
28
+ const name = propNameText(p.name);
29
+ if (name === 'dir' && ts.isStringLiteral(p.initializer) && dir === undefined) {
30
+ dir = p.initializer.text;
31
+ }
32
+ if (name === 'fallbackLng' && ts.isStringLiteral(p.initializer) && fallbackLng === undefined) {
33
+ fallbackLng = p.initializer.text;
34
+ }
35
+ }
36
+ return;
37
+ }
38
+ if (ts.isConditionalExpression(node)) {
39
+ collect(node.whenTrue);
40
+ collect(node.whenFalse);
41
+ return;
42
+ }
43
+ if (ts.isBinaryExpression(node)) {
44
+ const op = node.operatorToken.kind;
45
+ if (op === ts.SyntaxKind.QuestionQuestionToken ||
46
+ op === ts.SyntaxKind.BarBarToken ||
47
+ op === ts.SyntaxKind.AmpersandAmpersandToken) {
48
+ collect(node.left);
49
+ collect(node.right);
50
+ }
51
+ }
52
+ };
53
+ const visit = (node) => {
54
+ if (ts.isCallExpression(node) && isDefineConfig(node.expression)) {
55
+ const arg = node.arguments[0];
56
+ if (arg && ts.isObjectLiteralExpression(arg)) {
57
+ for (const p of arg.properties) {
58
+ if (ts.isPropertyAssignment(p) && propNameText(p.name) === 'i18n') {
59
+ declared = true;
60
+ collect(p.initializer);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ ts.forEachChild(node, visit);
66
+ };
67
+ visit(sf);
68
+ return { declared, dir, fallbackLng };
69
+ }
70
+ /** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
71
+ export function analyzeProjectI18n(cwd) {
72
+ const configPath = join(cwd, 'gaon.config.ts');
73
+ if (!existsSync(configPath))
74
+ return { declared: false };
75
+ try {
76
+ return analyzeConfigI18n(readFileSync(configPath, 'utf8'));
77
+ }
78
+ catch {
79
+ return { declared: false };
80
+ }
81
+ }
82
+ function isDefineConfig(e) {
83
+ if (ts.isIdentifier(e) && e.text === 'defineConfig')
84
+ return true;
85
+ if (ts.isPropertyAccessExpression(e) && e.name.text === 'defineConfig')
86
+ return true;
87
+ return false;
88
+ }
89
+ function propNameText(n) {
90
+ if (ts.isIdentifier(n))
91
+ return n.text;
92
+ if (ts.isStringLiteral(n))
93
+ return n.text;
94
+ return undefined;
95
+ }
package/dist/index.d.ts CHANGED
@@ -64,6 +64,12 @@ export interface ParsedNewArgs {
64
64
  readonly packageManager?: "pnpm" | "npm" | "yarn";
65
65
  /** pm 값이 주어졌으나 pnpm·npm·yarn 이 아닐 때 그 원문(에러 안내용). */
66
66
  readonly unknownPm?: string;
67
+ /**
68
+ * 결정 314: 파사드 버전 스펙(예 `^1.21.0`). create-gaon 이 자기 발행 메타에서
69
+ * 해석해 주입한다 — npx 설치 트리엔 gaonjs 패키지가 없어 상향 탐색(결정 242)이
70
+ * fail-loud 로 죽기 때문. 미지정이면 기존 자동 감지 경로 그대로.
71
+ */
72
+ readonly gaonjsVersion?: string;
67
73
  }
68
74
  /**
69
75
  * `gaon new <name>` 의 인자를 파싱한다.
package/dist/index.js CHANGED
@@ -110,12 +110,12 @@ function renderHelp(version = VERSION) {
110
110
  " gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
111
111
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
112
112
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
113
- ` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지)`,
113
+ ` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지·render return 누락)`,
114
114
  " gaon doctor --json 자동화용 JSON 출력",
115
115
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
116
116
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
117
117
  " gaon doctor --fix --yes 실제 편집 적용(원본은 .bak-<타임스탬프> 로 자동 백업)",
118
- " gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트)",
118
+ " gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트 · --jwt --app <api> = API 앱 토큰 변형)",
119
119
  " gaon g controller <name> 컨트롤러 스캐폴드 (Rails 관례 · 페이지+JSON 액션)",
120
120
  " gaon g model <Name> 모델 스캐폴드 (스키마+모델 · E-4 컬럼 예시)",
121
121
  " gaon g page <Path/Name> Vue 페이지 (Inertia SPA · pageProps 브리지)",
@@ -147,7 +147,7 @@ function renderHelp(version = VERSION) {
147
147
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
148
148
  */
149
149
  export function parseDoctorChecks(argv) {
150
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 27종)를 단일 출처로 쓴다 — 과거
150
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 28종)를 단일 출처로 쓴다 — 과거
151
151
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
152
152
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
153
153
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -172,12 +172,22 @@ export function parseDoctorChecks(argv) {
172
172
  export function parseNewArgs(rest) {
173
173
  let name;
174
174
  let pmRaw;
175
+ let gaonjsVersion;
175
176
  for (let i = 0; i < rest.length; i++) {
176
177
  const a = rest[i];
177
178
  if (a === "--package-manager" || a === "--pm") {
178
179
  pmRaw = rest[++i];
179
180
  continue;
180
181
  }
182
+ // 결정 314: 값을 취하는 플래그라 pm 과 같은 이유로 값을 건너뛴다(이름 오인 방지).
183
+ if (a === "--gaonjs-version") {
184
+ gaonjsVersion = rest[++i];
185
+ continue;
186
+ }
187
+ if (a.startsWith("--gaonjs-version=")) {
188
+ gaonjsVersion = a.slice("--gaonjs-version=".length);
189
+ continue;
190
+ }
181
191
  const eqPrefix = a.startsWith("--package-manager=")
182
192
  ? "--package-manager="
183
193
  : a.startsWith("--pm=")
@@ -192,10 +202,15 @@ export function parseNewArgs(rest) {
192
202
  if (name === undefined)
193
203
  name = a;
194
204
  }
205
+ const version = gaonjsVersion === undefined || gaonjsVersion === "" ? undefined : gaonjsVersion;
195
206
  if (pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn") {
196
- return { name, packageManager: pmRaw };
207
+ return { name, packageManager: pmRaw, gaonjsVersion: version };
197
208
  }
198
- return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
209
+ return {
210
+ name,
211
+ unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw,
212
+ gaonjsVersion: version,
213
+ };
199
214
  }
200
215
  /**
201
216
  * `gaon db <sub>` 의 인자를 파싱한다(argv = 전체 · argv[0]='db', argv[1]=sub).
@@ -210,6 +225,10 @@ export function parseDbArgs(sub, argv) {
210
225
  const dbIdx = argv.indexOf("--db");
211
226
  const cfgIdx = argv.indexOf("--config");
212
227
  const valueFlags = new Set(["--db", "--config"]);
228
+ // 결정 358: db 는 파괴 방향 오동작이 가능한 명령군이라 미지 플래그를 조용히
229
+ // 무시하지 않는다 — `--dryrun`(오타)이 무시되면 dry-run 의도가 **실제 마이그레이션
230
+ // 적용**으로 반전된다(결정 267 down-위치 문제와 동형). 인정 집합 밖 `--*` 는 throw.
231
+ const knownFlags = new Set(["--db", "--config", "--json", "--yes", "--dry-run"]);
213
232
  const positionals = [];
214
233
  for (let i = 2; i < argv.length; i++) {
215
234
  const a = argv[i];
@@ -219,8 +238,14 @@ export function parseDbArgs(sub, argv) {
219
238
  i++; // 플래그 값 스킵
220
239
  continue;
221
240
  }
222
- if (a.startsWith("--"))
223
- continue; // 불리언 플래그
241
+ if (a.startsWith("--")) {
242
+ if (!knownFlags.has(a)) {
243
+ throw new Error(`gaon db ${sub}: 알 수 없는 플래그 '${a}'.\n` +
244
+ ` → 지원 플래그: --db <키> · --config <경로> · --dry-run · --yes · --json\n` +
245
+ ` → 오타(예: --dryrun)가 조용히 무시되면 의도와 반대로 실행될 수 있어 멈춥니다.`);
246
+ }
247
+ continue; // 인정된 불리언 플래그
248
+ }
224
249
  positionals.push(a);
225
250
  }
226
251
  return {
@@ -393,7 +418,7 @@ export function runCli(argv, opts = {}) {
393
418
  });
394
419
  return;
395
420
  }
396
- // `gaon doctor` — 정적 검사(M9-E · 27 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
421
+ // `gaon doctor` — 정적 검사(M9-E · 28 검사 · ALL_RULES 단일 출처). --check=<이름>[,<이름>...] 로
397
422
  // 선택 실행, --json 은 자동화 파싱용.
398
423
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
399
424
  if (argv[0] === "doctor") {
@@ -406,11 +431,15 @@ export function runCli(argv, opts = {}) {
406
431
  process.exitCode = 2;
407
432
  return;
408
433
  }
409
- // --fix --yes: 편집이 실제로 일어났고 나머지 위반이 없으면 0,
410
- // 아직 수동 수정이 남아 있으면 1(자동화 정합).
411
- // --fix (dry-run): errors > 0 이면 1( 고침) · 계획만 있어도 1.
434
+ // 결정 359: 종료 코드 판정.
435
+ // --fix --yes: runDoctorCommand 적용 후 **재검사** 결과를 돌려주므로
436
+ // errors = 잔여 위반 전부 자동 수정됐으면 0(자동화 정합).
437
+ // --fix (dry-run): errors > 0 이면 1 · errors 0 이어도 **계획이 있으면 1**
438
+ // (고칠 게 있다는 신호 — 이전엔 warning-only 계획이 0 으로 삼켜짐).
412
439
  // 기본: errors > 0 이면 1.
413
- process.exitCode = result.errors.length > 0 ? 1 : 0;
440
+ const fix = result.fix;
441
+ const dryPlans = fix !== undefined && !fix.applied && fix.outcomes.length > 0;
442
+ process.exitCode = result.errors.length > 0 || dryPlans ? 1 : 0;
414
443
  })
415
444
  .catch((err) => {
416
445
  const msg = err instanceof Error ? err.message : String(err);
@@ -483,7 +512,16 @@ export function runCli(argv, opts = {}) {
483
512
  process.exitCode = 1;
484
513
  return;
485
514
  }
486
- const dbOpts = parseDbArgs(sub, argv);
515
+ // 결정 358: 미지 플래그는 파싱 경계에서 fail-loud(오타 dry-run 이 실 적용되는 반전 방지).
516
+ let dbOpts;
517
+ try {
518
+ dbOpts = parseDbArgs(sub, argv);
519
+ }
520
+ catch (err) {
521
+ process.stderr.write(` ✗ ${err instanceof Error ? err.message : String(err)}\n`);
522
+ process.exitCode = 1;
523
+ return;
524
+ }
487
525
  void runDbCommand(sub, dbOpts)
488
526
  .then((code) => {
489
527
  process.exitCode = code;
@@ -503,7 +541,13 @@ export function runCli(argv, opts = {}) {
503
541
  const appIdx = argv.indexOf("--app");
504
542
  const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
505
543
  // 결정 155: --public 은 비-web 앱도 공개 회원가입을 opt-in(공개 비-web 앱 탈출구).
506
- const code = runGenerateAuthCommand({ app, json: argv.includes("--json"), public: argv.includes("--public") });
544
+ // 결정 338: --jwt API 앱 토큰 변형(--app 필수 · --public 배타).
545
+ const code = runGenerateAuthCommand({
546
+ app,
547
+ json: argv.includes("--json"),
548
+ public: argv.includes("--public"),
549
+ jwt: argv.includes("--jwt"),
550
+ });
507
551
  process.exitCode = code;
508
552
  return;
509
553
  }
@@ -588,6 +632,7 @@ export function runCli(argv, opts = {}) {
588
632
  skipInstall: argv.includes("--skip-install"),
589
633
  skipGit: argv.includes("--skip-git"),
590
634
  packageManager,
635
+ gaonjsVersion: parsed.gaonjsVersion,
591
636
  })
592
637
  .then((code) => {
593
638
  process.exitCode = code;
@@ -93,7 +93,7 @@ export declare function readAgentDocTool(args: ToolArgs, cwd: string): Promise<T
93
93
  * AI 에이전트가 자기 산출물을 스스로 확증하는 표준 경로.
94
94
  *
95
95
  * 인자:
96
- * { only?: 'typecheck'|'vue-tsc'|'build'|'doctor', includeDoctor?: boolean }
96
+ * { only?: 'typecheck'|'vue-tsc'|'build'|'doctor', noDoctor?: boolean }
97
97
  */
98
98
  export declare function runCheckTool(args: ToolArgs, cwd: string): Promise<ToolResult>;
99
99
  /**
package/dist/mcp/tools.js CHANGED
@@ -434,17 +434,20 @@ function parseJsonTail(output) {
434
434
  * AI 에이전트가 자기 산출물을 스스로 확증하는 표준 경로.
435
435
  *
436
436
  * 인자:
437
- * { only?: 'typecheck'|'vue-tsc'|'build'|'doctor', includeDoctor?: boolean }
437
+ * { only?: 'typecheck'|'vue-tsc'|'build'|'doctor', noDoctor?: boolean }
438
438
  */
439
439
  export async function runCheckTool(args, cwd) {
440
440
  const only = stringOpt(args, 'only');
441
- const includeDoctor = boolOpt(args, 'includeDoctor');
441
+ // 결정 362: doctor 는 check 에 **기본 포함**(결정 157). 구 파라미터 includeDoctor
442
+ // no-op CLI 플래그(--include-doctor)로 흘러 "false 여도 doctor 가 도는" 유령이었다 —
443
+ // 실 토글은 noDoctor(--no-doctor)다. includeDoctor 는 하위 호환으로 받되 무시한다.
444
+ const noDoctor = boolOpt(args, 'noDoctor');
442
445
  const known = ['typecheck', 'vue-tsc', 'build', 'doctor'];
443
446
  if (only && !known.includes(only)) {
444
447
  return errorResult(`only 는 ${known.map((k) => `'${k}'`).join(' | ')} 중 하나여야 합니다. 입력값: '${only}'\n` +
445
448
  `→ 인자를 고치거나 생략해 전체 검사를 실행하세요.`);
446
449
  }
447
- const cliArgs = ['check', '--json', ...(only ? ['--only', only] : []), ...(includeDoctor ? ['--include-doctor'] : [])];
450
+ const cliArgs = ['check', '--json', ...(only ? ['--only', only] : []), ...(noDoctor ? ['--no-doctor'] : [])];
448
451
  const r = await spawnGaon(cwd, cliArgs);
449
452
  if ('error' in r)
450
453
  return errorResult(r.error);
@@ -454,7 +457,7 @@ export async function runCheckTool(args, cwd) {
454
457
  ok: r.exitCode === 0,
455
458
  exitCode: r.exitCode,
456
459
  only: only ?? null,
457
- includeDoctor,
460
+ noDoctor: noDoctor ?? false,
458
461
  report: parseJsonTail(r.output) ?? null,
459
462
  output: r.output,
460
463
  },
@@ -590,7 +593,7 @@ export const TOOLS = [
590
593
  name: 'run_check',
591
594
  description: '`gaon check` 를 실 실행한다 — 검사 전에 .gaon 타입 브리지를 재생성(규칙 3)한 뒤 ' +
592
595
  'typecheck·vue-tsc·build 를 순서대로 돌린다. 산출물을 고친 뒤 스스로 확증하는 표준 경로 ' +
593
- '(CLAUDE.md §3 검증 루프). only 로 한 단계만, includeDoctor 로 doctor 까지 포함.',
596
+ '(CLAUDE.md §3 검증 루프). doctor 는 기본 포함(결정 157) — only 로 한 단계만, noDoctor 로 doctor 제외.',
594
597
  inputSchema: {
595
598
  type: 'object',
596
599
  properties: {
@@ -599,7 +602,7 @@ export const TOOLS = [
599
602
  enum: ['typecheck', 'vue-tsc', 'build', 'doctor'],
600
603
  description: '지정 시 그 검사 하나만 실행. 생략 시 전체.',
601
604
  },
602
- includeDoctor: { type: 'boolean', description: 'true 면 doctor 함께 실행. 기본 false.' },
605
+ noDoctor: { type: 'boolean', description: 'true 면 doctor 제외(기본 false — doctor 는 기본 포함 · 결정 157).' },
603
606
  },
604
607
  additionalProperties: false,
605
608
  },
@@ -3,4 +3,4 @@
3
3
  * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
4
4
  * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
5
5
  */
6
- export declare function generateMessagesDts(localesDir: string, out: string): boolean;
6
+ export declare function generateMessagesDts(localesDir: string, out: string, baseLng?: string): boolean;
@@ -12,13 +12,16 @@ import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
12
12
  * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
13
13
  * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
14
14
  */
15
- export function generateMessagesDts(localesDir, out) {
15
+ export function generateMessagesDts(localesDir, out, baseLng) {
16
16
  if (!existsSync(localesDir))
17
17
  return false;
18
18
  const resources = loadLocales(localesDir);
19
19
  if (Object.keys(resources).length === 0)
20
20
  return false;
21
21
  mkdirSync(dirname(out), { recursive: true });
22
- writeFileSync(out, renderMessagesDts(resources), 'utf8');
22
+ // 결정 352: 기준 로케일 = config i18n.fallbackLng(호출자가 정적 분석으로 전달).
23
+ // 이전엔 항상 알파벳순 첫 로케일이라 컴파일 보증이 fallback 체인과 다른 로케일에
24
+ // 정박했다(en/ko + fallbackLng:'ko' 프로젝트에서 기준이 en).
25
+ writeFileSync(out, renderMessagesDts(resources, baseLng), 'utf8');
23
26
  return true;
24
27
  }