@gaonjs/cli 0.47.0 → 0.55.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 (59) hide show
  1. package/dist/commands/check.d.ts +3 -1
  2. package/dist/commands/check.js +44 -2
  3. package/dist/commands/db.js +9 -0
  4. package/dist/commands/gen.d.ts +2 -0
  5. package/dist/commands/gen.js +3 -1
  6. package/dist/commands/new.js +13 -0
  7. package/dist/commands/test.js +16 -3
  8. package/dist/db/journal.d.ts +8 -4
  9. package/dist/db/journal.js +58 -14
  10. package/dist/db/migrate.d.ts +3 -1
  11. package/dist/db/migrate.js +14 -14
  12. package/dist/db/replay.js +3 -3
  13. package/dist/db/resolve.d.ts +11 -1
  14. package/dist/db/resolve.js +24 -2
  15. package/dist/db/status.js +17 -4
  16. package/dist/db.js +26 -5
  17. package/dist/dev.d.ts +6 -4
  18. package/dist/dev.js +9 -4
  19. package/dist/doctor/fixers/index.d.ts +1 -1
  20. package/dist/doctor/fixers/index.js +6 -1
  21. package/dist/doctor/locale-parity.js +4 -1
  22. package/dist/doctor/render-return.d.ts +11 -0
  23. package/dist/doctor/render-return.js +143 -0
  24. package/dist/doctor/types.d.ts +1 -1
  25. package/dist/doctor.d.ts +3 -2
  26. package/dist/doctor.js +16 -5
  27. package/dist/generate.d.ts +20 -1
  28. package/dist/generate.js +120 -21
  29. package/dist/hub.js +2 -0
  30. package/dist/i18n-config.d.ts +32 -0
  31. package/dist/i18n-config.js +170 -0
  32. package/dist/index.js +141 -29
  33. package/dist/mcp/tools.d.ts +1 -1
  34. package/dist/mcp/tools.js +13 -6
  35. package/dist/messages-gen.d.ts +1 -1
  36. package/dist/messages-gen.js +16 -5
  37. package/dist/templates/auth/Dashboard.vue.tpl +3 -2
  38. package/dist/templates/auth/Login.vue.tpl +3 -5
  39. package/dist/templates/auth/Signup.vue.tpl +3 -5
  40. package/dist/templates/auth/jwt.app.config.ts.tpl +18 -0
  41. package/dist/templates/auth/jwt.auth.wiring.ts.tpl +16 -0
  42. package/dist/templates/auth/jwt.routes.ts.tpl +7 -0
  43. package/dist/templates/auth/jwt.session.controller.ts.tpl +36 -0
  44. package/dist/templates/project/AGENTS.md.tpl +3 -2
  45. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  46. package/dist/templates/project/Dockerfile.tpl +11 -1
  47. package/dist/templates/project/agents/async.md.tpl +70 -14
  48. package/dist/templates/project/agents/data.md.tpl +173 -52
  49. package/dist/templates/project/agents/frontend.md.tpl +19 -4
  50. package/dist/templates/project/agents/i18n.md.tpl +20 -2
  51. package/dist/templates/project/agents/mail.md.tpl +8 -1
  52. package/dist/templates/project/agents/realtime.md.tpl +25 -6
  53. package/dist/templates/project/agents/seal.md.tpl +8 -3
  54. package/dist/templates/project/agents/security.md.tpl +50 -19
  55. package/dist/templates/project/agents/storage.md.tpl +39 -6
  56. package/dist/templates/project/agents/web.md.tpl +54 -22
  57. package/dist/work.d.ts +3 -0
  58. package/dist/work.js +4 -0
  59. package/package.json +7 -7
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,32 @@
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
+ /** 정적 리터럴 배열로 읽힌 supportedLngs(결정 413 · check 미러용). 못 읽으면 undefined. */
9
+ readonly supportedLngs?: readonly string[];
10
+ /**
11
+ * 결정 412: **선언은 있는데 정적으로 못 읽은** 키들(예 `dir: LOCALES_DIR`).
12
+ * 이 경우 런타임은 실값을, 타입 축·doctor 는 폴백('locales'·정렬 첫 로케일)을 써서
13
+ * 두 축이 갈라진다 — 호출자가 경고를 낼 수 있게 표면화한다(무신호 금지).
14
+ */
15
+ readonly unresolved: readonly ('dir' | 'fallbackLng')[];
16
+ }
17
+ /** gaon.config.ts 소스에서 i18n 블록의 dir·fallbackLng 리터럴을 뽑는다. */
18
+ export declare function analyzeConfigI18n(source: string): ConfigI18nAnalysis;
19
+ /** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
20
+ export declare function analyzeProjectI18n(cwd: string): ConfigI18nAnalysis;
21
+ /**
22
+ * 결정 412: 카탈로그 디렉터리의 절대 경로. `dir` 이 절대 경로면 그대로 쓴다 —
23
+ * wire(런타임)는 이미 그렇게 해석하는데 CLI 축만 무조건 join 해서
24
+ * `join('/proj','/var/locales')` = `/proj/var/locales` 로 조용히 빗나갔다.
25
+ */
26
+ export declare function resolveLocalesDir(cwd: string, dir: string | undefined): string;
27
+ /**
28
+ * 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
29
+ * 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 카탈로그를 보거나
30
+ * 아예 안 생길 수 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
31
+ */
32
+ export declare function warnUnresolvedI18n(analysis: ConfigI18nAnalysis, write: (s: string) => void): void;
@@ -0,0 +1,170 @@
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
+ let supportedLngs;
19
+ const unresolved = new Set();
20
+ // 결정 412: `as`·`satisfies` 래핑을 벗긴다 — defineConfig({...} satisfies GaonConfig)
21
+ // 는 실사용 형태인데 종전엔 객체 리터럴이 아니라고 보고 i18n 선언 자체를 놓쳤다.
22
+ const unwrap = (node) => {
23
+ let e = node;
24
+ while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) {
25
+ e = e.expression;
26
+ }
27
+ return e;
28
+ };
29
+ // 문자열 리터럴 + 치환 없는 템플릿 리터럴(`translations`)을 함께 읽는다 — 개발자
30
+ // 눈에는 똑같은 리터럴인데 종전엔 템플릿만 무소음으로 빠졌다(결정 412).
31
+ const literalText = (e) => {
32
+ const n = unwrap(e);
33
+ if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n))
34
+ return n.text;
35
+ return undefined;
36
+ };
37
+ // i18n 초기화식에서 객체 리터럴을 찾는다 — 삼항·??·&&·괄호는 양변을 훑는다
38
+ // (connections.ts 와 동일 규약 · env 조건부 블록 대응). 두 분기가 서로 다른
39
+ // 리터럴을 주는 병리적 케이스는 먼저 읽힌 값을 쓴다(실사용 형태 아님).
40
+ const collect = (raw) => {
41
+ const node = unwrap(raw);
42
+ if (ts.isObjectLiteralExpression(node)) {
43
+ for (const p of node.properties) {
44
+ // 결정 412: shorthand(`{ i18n }`)·스프레드는 값을 여기서 알 수 없다 — 조용히
45
+ // 넘기지 않고 미해석으로 표시한다.
46
+ if (ts.isShorthandPropertyAssignment(p) || ts.isSpreadAssignment(p)) {
47
+ unresolved.add('dir');
48
+ unresolved.add('fallbackLng');
49
+ continue;
50
+ }
51
+ if (!ts.isPropertyAssignment(p))
52
+ continue;
53
+ const name = propNameText(p.name);
54
+ // 결정 413: supportedLngs 는 리터럴 배열일 때만 읽는다(check 정적 미러용).
55
+ if (name === 'supportedLngs' && supportedLngs === undefined) {
56
+ const arr = unwrap(p.initializer);
57
+ if (ts.isArrayLiteralExpression(arr)) {
58
+ const items = arr.elements.map((el) => literalText(el));
59
+ if (items.every((v) => v !== undefined))
60
+ supportedLngs = items;
61
+ }
62
+ continue;
63
+ }
64
+ if (name !== 'dir' && name !== 'fallbackLng')
65
+ continue;
66
+ const text = literalText(p.initializer);
67
+ if (text === undefined) {
68
+ // 변수 참조·env 표현식 등 — 런타임 실값과 갈라지는 지점.
69
+ unresolved.add(name);
70
+ continue;
71
+ }
72
+ if (name === 'dir' && dir === undefined)
73
+ dir = text;
74
+ if (name === 'fallbackLng' && fallbackLng === undefined)
75
+ fallbackLng = text;
76
+ }
77
+ return;
78
+ }
79
+ if (ts.isConditionalExpression(node)) {
80
+ collect(node.whenTrue);
81
+ collect(node.whenFalse);
82
+ return;
83
+ }
84
+ if (ts.isBinaryExpression(node)) {
85
+ const op = node.operatorToken.kind;
86
+ if (op === ts.SyntaxKind.QuestionQuestionToken ||
87
+ op === ts.SyntaxKind.BarBarToken ||
88
+ op === ts.SyntaxKind.AmpersandAmpersandToken) {
89
+ collect(node.left);
90
+ collect(node.right);
91
+ }
92
+ }
93
+ };
94
+ const visit = (node) => {
95
+ if (ts.isCallExpression(node) && isDefineConfig(node.expression)) {
96
+ const arg = node.arguments[0];
97
+ const obj = arg ? unwrap(arg) : undefined;
98
+ if (obj && ts.isObjectLiteralExpression(obj)) {
99
+ for (const p of obj.properties) {
100
+ if (ts.isShorthandPropertyAssignment(p) && p.name.text === 'i18n') {
101
+ // `defineConfig({ i18n })` — 선언은 확실하나 값은 정적으로 못 읽는다.
102
+ declared = true;
103
+ unresolved.add('dir');
104
+ unresolved.add('fallbackLng');
105
+ continue;
106
+ }
107
+ if (ts.isPropertyAssignment(p) && propNameText(p.name) === 'i18n') {
108
+ declared = true;
109
+ collect(p.initializer);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ ts.forEachChild(node, visit);
115
+ };
116
+ visit(sf);
117
+ // 읽힌 키는 미해석 목록에서 뺀다(한 분기만 리터럴인 경우 값이 있으면 그 값을 쓴다).
118
+ if (dir !== undefined)
119
+ unresolved.delete('dir');
120
+ if (fallbackLng !== undefined)
121
+ unresolved.delete('fallbackLng');
122
+ return { declared, dir, fallbackLng, supportedLngs, unresolved: [...unresolved] };
123
+ }
124
+ /** cwd 의 gaon.config.ts 를 읽어 i18n 블록을 분석한다. 파일이 없으면 미선언. */
125
+ export function analyzeProjectI18n(cwd) {
126
+ const configPath = join(cwd, 'gaon.config.ts');
127
+ if (!existsSync(configPath))
128
+ return { declared: false, unresolved: [] };
129
+ try {
130
+ return analyzeConfigI18n(readFileSync(configPath, 'utf8'));
131
+ }
132
+ catch {
133
+ return { declared: false, unresolved: [] };
134
+ }
135
+ }
136
+ /**
137
+ * 결정 412: 카탈로그 디렉터리의 절대 경로. `dir` 이 절대 경로면 그대로 쓴다 —
138
+ * wire(런타임)는 이미 그렇게 해석하는데 CLI 축만 무조건 join 해서
139
+ * `join('/proj','/var/locales')` = `/proj/var/locales` 로 조용히 빗나갔다.
140
+ */
141
+ export function resolveLocalesDir(cwd, dir) {
142
+ const d = dir ?? 'locales';
143
+ return d.startsWith('/') ? d : join(cwd, d);
144
+ }
145
+ /**
146
+ * 결정 412: 정적으로 못 읽은 i18n 키를 한 줄 경고로 알린다(무신호 금지). 런타임은
147
+ * 실값을, 타입 축·doctor 는 폴백을 쓰므로 messages.d.ts 가 엉뚱한 카탈로그를 보거나
148
+ * 아예 안 생길 수 있다 — 어느 형태로 바꾸면 되는지까지 쓴다(§7.5.3).
149
+ */
150
+ export function warnUnresolvedI18n(analysis, write) {
151
+ if (analysis.unresolved.length === 0)
152
+ return;
153
+ write(` ! gaon.config.ts 의 i18n ${analysis.unresolved.join('·')} 을(를) 정적으로 읽지 못했습니다 — ` +
154
+ `타입 축(.gaon/messages.d.ts)과 doctor 는 기본값(dir='locales' · 기준=정렬 첫 로케일)을 씁니다.\n` +
155
+ ` → 문자열 리터럴로 직접 쓰면 정확히 반영됩니다: i18n: { dir: 'locales', fallbackLng: 'ko' }\n`);
156
+ }
157
+ function isDefineConfig(e) {
158
+ if (ts.isIdentifier(e) && e.text === 'defineConfig')
159
+ return true;
160
+ if (ts.isPropertyAccessExpression(e) && e.name.text === 'defineConfig')
161
+ return true;
162
+ return false;
163
+ }
164
+ function propNameText(n) {
165
+ if (ts.isIdentifier(n))
166
+ return n.text;
167
+ if (ts.isStringLiteral(n))
168
+ return n.text;
169
+ return undefined;
170
+ }