@gaonjs/cli 0.32.0 → 0.34.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 (57) hide show
  1. package/dist/commands/build.d.ts +11 -0
  2. package/dist/commands/build.js +49 -0
  3. package/dist/commands/check.d.ts +10 -1
  4. package/dist/commands/check.js +51 -6
  5. package/dist/commands/dev.js +2 -0
  6. package/dist/commands/gen.js +4 -2
  7. package/dist/db/projectData.d.ts +14 -0
  8. package/dist/db/projectData.js +51 -0
  9. package/dist/db.js +38 -2
  10. package/dist/dev/build.d.ts +34 -0
  11. package/dist/dev/build.js +144 -0
  12. package/dist/dev/frontend-build.d.ts +3 -3
  13. package/dist/dev/frontend-build.js +42 -37
  14. package/dist/dev.d.ts +11 -1
  15. package/dist/dev.js +29 -1
  16. package/dist/doctor/async-offload.d.ts +3 -1
  17. package/dist/doctor/async-offload.js +5 -2
  18. package/dist/doctor/auth-wiring.d.ts +4 -2
  19. package/dist/doctor/auth-wiring.js +13 -6
  20. package/dist/doctor/csrf-wiring.d.ts +3 -1
  21. package/dist/doctor/csrf-wiring.js +9 -5
  22. package/dist/doctor/seal-security.js +12 -6
  23. package/dist/doctor/source-scan.d.ts +11 -0
  24. package/dist/doctor/source-scan.js +70 -0
  25. package/dist/generate.d.ts +12 -3
  26. package/dist/generate.js +113 -42
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +25 -5
  29. package/dist/messages-gen.d.ts +6 -0
  30. package/dist/messages-gen.js +24 -0
  31. package/dist/scaffold/app.js +2 -1
  32. package/dist/templates/auth/Dashboard.vue.tpl +2 -2
  33. package/dist/templates/auth/Login.vue.tpl +2 -5
  34. package/dist/templates/auth/Signup.vue.tpl +2 -2
  35. package/dist/templates/auth/app.config.ts.tpl +3 -3
  36. package/dist/templates/auth/dashboard.controller.ts.tpl +1 -1
  37. package/dist/templates/auth/dashboard.secure.controller.ts.tpl +14 -0
  38. package/dist/templates/auth/registration.controller.ts.tpl +3 -3
  39. package/dist/templates/auth/session.controller.ts.tpl +5 -5
  40. package/dist/templates/project/.env.example.tpl +6 -0
  41. package/dist/templates/project/AGENTS.md.tpl +3 -1
  42. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  43. package/dist/templates/project/agents/async.md.tpl +46 -5
  44. package/dist/templates/project/agents/data.md.tpl +47 -2
  45. package/dist/templates/project/agents/frontend.md.tpl +3 -1
  46. package/dist/templates/project/agents/i18n.md.tpl +107 -0
  47. package/dist/templates/project/agents/mail.md.tpl +92 -0
  48. package/dist/templates/project/agents/realtime.md.tpl +8 -0
  49. package/dist/templates/project/agents/security.md.tpl +45 -2
  50. package/dist/templates/project/agents/testing.md.tpl +11 -0
  51. package/dist/templates/project/agents/web.md.tpl +32 -0
  52. package/dist/templates/project/gaon.config.ts.tpl +14 -0
  53. package/dist/templates/project/package.json.tpl +1 -1
  54. package/dist/templates/project/vite.config.ts.tpl +6 -5
  55. package/package.json +7 -6
  56. package/dist/templates/auth/app.ts.tpl +0 -29
  57. package/dist/templates/auth/server.ts.tpl +0 -14
package/dist/generate.js CHANGED
@@ -16,45 +16,90 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
18
  import { authUiKitFiles, writeUiKitFiles } from './uikit.js';
19
+ /** 결정 155: 이 스캐폴드가 공개 회원가입을 포함하는가(web 기본 O · 비-web 은 --public 시만). */
20
+ function includesPublicRegistration(opts) {
21
+ const app = opts.app ?? 'web';
22
+ return app === 'web' || opts.public === true;
23
+ }
19
24
  // ── 템플릿 로드·치환 ───────────────────────────────────────────
20
25
  // 템플릿은 이 모듈과 같은 위치의 templates/auth/ 에 있다(빌드가 dist 로 복사).
21
26
  const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'auth');
22
- /** 템플릿 파일을 읽어 토큰을 치환한다. {{APP_NAME}} 는 Vue 의 {{ }} 보간과
23
- * 겹치지 않는 고정 리터럴이라 단순 replaceAll 안전하다. */
24
- function renderTemplate(name, app) {
27
+ /**
28
+ * 앱의 URL 프리픽스 web 루트라 '' (URL 앞에 아무것도 안 붙음), 그 외는 '/<app>'
29
+ * (결정 141 · 12차 W4). 폴더명=프리픽스 관례(§3.3)와 정합. 라우트 **등록** 경로는
30
+ * 프레임웍이 앱 스코프에서 자동으로 프리픽스를 붙이므로 이 값은 라우트 등록이 아니라
31
+ * **절대 URL**(form.post·router.delete·Link href·redirect·loginRedirect)에만 쓴다.
32
+ */
33
+ function urlPrefixFor(app) {
34
+ return app === 'web' ? '' : `/${app}`;
35
+ }
36
+ /**
37
+ * 앱의 세션 secret 환경변수명 — web 은 'SESSION_SECRET'(기존), 그 외는
38
+ * '<APP>_SESSION_SECRET'(결정 141 · 앱별 세션 완전 분리 · session.ts 는 앱마다
39
+ * secret 이 달라야 분리가 완성된다고 명시). 앱 이름의 비영숫자는 '_' 로 정규화.
40
+ */
41
+ function sessionSecretEnvFor(app) {
42
+ if (app === 'web')
43
+ return 'SESSION_SECRET';
44
+ return `${app.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_SESSION_SECRET`;
45
+ }
46
+ /** 앱별 개발용 세션 secret 폴백(32자 이상 · 앱마다 다르게). */
47
+ function devSessionSecretFor(app) {
48
+ return `dev-only-session-secret-${app}-change-me-now!!`;
49
+ }
50
+ /** 결정 155: Login 페이지의 회원가입 링크 — 공개 가입 앱에만 넣는다(시큐어 앱은 뺀다).
51
+ * 템플릿의 `{{SIGNUP_LINK}}` 자리(8칸 들여쓰기)에 in-place 치환되므로 첫 줄은 들여쓰기 없이. */
52
+ function signupLinkMarkup(app) {
53
+ return (`<p class="mt-4 text-center text-sm text-muted-foreground">\n` +
54
+ ` 계정이 없으신가요?\n` +
55
+ ` <Link href="${urlPrefixFor(app)}/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>\n` +
56
+ ` </p>`);
57
+ }
58
+ /** 템플릿 파일을 읽어 토큰을 치환한다. {{APP_NAME}}·{{URL_PREFIX}}·{{SESSION_SECRET_ENV}}·
59
+ * {{SIGNUP_LINK}} 는 Vue 의 {{ }} 보간과 겹치지 않는 고정 리터럴이라 단순 replaceAll 로
60
+ * 안전하다(결정 141·155). SIGNUP_LINK 는 공개 가입 앱에만 링크를, 시큐어 앱엔 빈 값. */
61
+ function renderTemplate(name, app, includePublic) {
25
62
  const raw = readFileSync(join(TEMPLATE_DIR, name), 'utf8');
26
- return raw.replaceAll('{{APP_NAME}}', app);
63
+ return raw
64
+ .replaceAll('{{APP_NAME}}', app)
65
+ .replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
66
+ .replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app))
67
+ .replaceAll('{{SIGNUP_LINK}}', includePublic ? signupLinkMarkup(app) : '');
27
68
  }
28
- /** 템플릿 파일 생성 경로 매핑(라우트 제외 라우트는 패치로 처리). */
29
- const TEMPLATES = [
30
- { tpl: 'user.schema.ts.tpl', out: () => 'domain/schema/users.ts' },
31
- { tpl: 'user.model.ts.tpl', out: () => 'domain/models/User.ts' },
32
- { tpl: 'auth.wiring.ts.tpl', out: (a) => `apps/${a}/auth.ts` },
33
- { tpl: 'session.controller.ts.tpl', out: (a) => `apps/${a}/controllers/session.ts` },
34
- { tpl: 'registration.controller.ts.tpl', out: (a) => `apps/${a}/controllers/registration.ts` },
35
- { tpl: 'dashboard.controller.ts.tpl', out: (a) => `apps/${a}/controllers/dashboard.ts` },
36
- // 결정 32·46: Vue 페이지 경로 세그먼트는 PascalCase(Route 이름) — 'Auth/'.
37
- // examples/blog 정본과 page-filename doctor 규칙에 정합(소문자 'auth/' 는
38
- // doctor 가 error 로 잡던 스캐폴드 표류였다 · W10 실측).
39
- { tpl: 'Login.vue.tpl', out: (a) => `apps/${a}/pages/Auth/Login.vue` },
40
- { tpl: 'Signup.vue.tpl', out: (a) => `apps/${a}/pages/Auth/Signup.vue` },
41
- { tpl: 'Dashboard.vue.tpl', out: (a) => `apps/${a}/pages/Dashboard.vue` },
42
- // 결정 59: 세션·인증 배선은 app.config.ts — 표준 부팅(gaon dev/serve = wireGaon)이
43
- // 소비한다. 과거의 수동 부팅 스캐폴드(app.ts·server.ts)는 두 번째 부팅 경로를
44
- // 만들어 One Way 를 깨고, 표준 경로에는 auth 배선이 빠져 currentUser 가 영구
45
- // null 이 되는 파손을 남겼다 — 제거했다.
46
- { tpl: 'app.config.ts.tpl', out: (a) => `apps/${a}/app.config.ts` },
47
- ];
48
- /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다. */
69
+ /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
70
+ * 결정 155: 공개 가입 여부(includePublic)로 registration·Signup·dashboard 변형을 가른다. */
49
71
  export function authScaffoldFiles(opts = {}) {
50
72
  const app = opts.app ?? 'web';
51
- return TEMPLATES.map(({ tpl, out }) => ({ path: out(app), contents: renderTemplate(tpl, app) }));
73
+ const includePublic = includesPublicRegistration(opts);
74
+ const specs = [
75
+ { tpl: 'user.schema.ts.tpl', out: 'domain/schema/users.ts' },
76
+ { tpl: 'user.model.ts.tpl', out: 'domain/models/User.ts' },
77
+ { tpl: 'auth.wiring.ts.tpl', out: `apps/${app}/auth.ts` },
78
+ { tpl: 'session.controller.ts.tpl', out: `apps/${app}/controllers/session.ts` },
79
+ // 결정 155: 시큐어(비-web · 비-public) 앱은 역할 게이트 대시보드, 공개 앱은 requireAuth 대시보드.
80
+ {
81
+ tpl: includePublic ? 'dashboard.controller.ts.tpl' : 'dashboard.secure.controller.ts.tpl',
82
+ out: `apps/${app}/controllers/dashboard.ts`,
83
+ },
84
+ // 결정 32·46: Vue 페이지 경로 세그먼트는 PascalCase(Route 이름) — 'Auth/'.
85
+ { tpl: 'Login.vue.tpl', out: `apps/${app}/pages/Auth/Login.vue` },
86
+ { tpl: 'Dashboard.vue.tpl', out: `apps/${app}/pages/Dashboard.vue` },
87
+ // 결정 59: 세션·인증 배선은 app.config.ts — 표준 부팅(gaon dev/serve = wireGaon)이 소비한다.
88
+ { tpl: 'app.config.ts.tpl', out: `apps/${app}/app.config.ts` },
89
+ ];
90
+ // 결정 155: 공개 가입 앱만 회원가입 컨트롤러·페이지를 깐다. 시큐어 앱(admin 등)은 빼서
91
+ // "관리 앱에 공개 가입 + 로그인 고객 200" 위험 기본을 구조적으로 차단한다.
92
+ if (includePublic) {
93
+ specs.splice(4, 0, { tpl: 'registration.controller.ts.tpl', out: `apps/${app}/controllers/registration.ts` });
94
+ specs.push({ tpl: 'Signup.vue.tpl', out: `apps/${app}/pages/Auth/Signup.vue` });
95
+ }
96
+ return specs.map(({ tpl, out }) => ({ path: out, contents: renderTemplate(tpl, app, includePublic) }));
52
97
  }
53
98
  /**
54
99
  * 기존 routes.ts 에 세션·회원가입 리소스를 끼워 넣는다. 이미 있으면 null.
55
100
  * `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
56
101
  */
57
- export function patchRoutes(existing) {
102
+ export function patchRoutes(existing, includePublic = true) {
58
103
  if (existing.includes("resource('session')"))
59
104
  return null;
60
105
  const m = existing.match(/routes\(\s*\(\s*\w+\s*\)\s*=>\s*\{/);
@@ -64,9 +109,10 @@ export function patchRoutes(existing) {
64
109
  // dashboard 라우트도 함께 배선한다 — 없으면 스캐폴드 Dashboard.vue 의
65
110
  // pageProps<'…:dashboard#show'> 가 라우트 맵에서 해상되지 않아 vue-tsc 가
66
111
  // 깨진다(결정 60 클린룸 실측 — gaon new 프로젝트에 g auth 를 얹는 경로).
112
+ // 결정 155: 회원가입 리소스는 공개 가입 앱에만(시큐어 앱은 빼서 공개 가입 라우트 자체를 안 깐다).
67
113
  const inject = "\n r.get('/dashboard', 'dashboard#show') // 보호 페이지 (gaon g auth)" +
68
114
  "\n r.resource('session') // 로그인/로그아웃 (gaon g auth)" +
69
- "\n r.resource('registration') // 회원가입 (gaon g auth)";
115
+ (includePublic ? "\n r.resource('registration') // 회원가입 (gaon g auth)" : '');
70
116
  return existing.slice(0, insertAt) + inject + existing.slice(insertAt);
71
117
  }
72
118
  /**
@@ -76,18 +122,30 @@ export function patchRoutes(existing) {
76
122
  * · defineAppConfig 객체 리터럴을 못 찾으면 null (안전 · 상위에서 경고 폴백).
77
123
  * 성공 시 loadUser import 도 함께 보장한다.
78
124
  */
79
- export function patchAppConfigAddAuth(existing) {
125
+ export function patchAppConfigAddAuth(existing, app = 'web') {
80
126
  if (/\bauth\s*:/.test(existing))
81
127
  return null;
82
128
  const m = existing.match(/defineAppConfig\(\s*\{/);
83
129
  if (!m || m.index === undefined)
84
130
  return null;
85
131
  const insertAt = m.index + m[0].length;
86
- let out = existing.slice(0, insertAt) +
87
- "\n // 결정 59: 세션 userId 사용자 로드 배선(g auth). 이게 있어야 로그인 후" +
88
- "\n // this.currentUser / this.requireAuth() 실제 사용자를 받는다." +
89
- "\n auth: { loadUser, loginRedirect: '/session/new' }," +
90
- existing.slice(insertAt);
132
+ const prefix = urlPrefixFor(app);
133
+ // 결정 141(12차 W4): 대상 앱에 세션이 없으면(예: gaon g app 만든 config)
134
+ // 세션도 함께 배선한다 세션이 없으면 로그인이 저장될 곳이 없어 왕복이 깨지고,
135
+ // doctor 세션/CSRF 누락을 error 로 잡는다(결정 140). secret 은 앱별로 분리한다.
136
+ const hasSession = /\bsession\s*:/.test(existing);
137
+ const lines = [];
138
+ if (!hasSession) {
139
+ const secretEnv = sessionSecretEnvFor(app);
140
+ lines.push(` // 세션 secret 은 32자 이상 — .env 의 ${secretEnv} 로 주입한다(앱별 세션 완전 분리).`, ` session: { secret: process.env.${secretEnv} ?? '${devSessionSecretFor(app)}' },`);
141
+ }
142
+ lines.push(' // 결정 59: 세션 userId → 사용자 로드 배선(g auth). 이게 있어야 로그인 후', ' // this.currentUser / this.requireAuth() 가 실제 사용자를 받는다.', ` auth: { loadUser, loginRedirect: '${prefix}/session/new' },`);
143
+ // 결정 141: 닫는 괄호 오염 방지 — 빈 `defineAppConfig({})` 처럼 삽입 지점 바로 뒤가
144
+ // 닫는 `}` 면 삽입 블록 끝에 개행을 더해 `})` 를 제 줄로 내린다(`},})` 방지).
145
+ const rest = existing.slice(insertAt);
146
+ const closesImmediately = /^\s*\}/.test(rest);
147
+ const block = '\n' + lines.join('\n') + (closesImmediately ? '\n' : '');
148
+ let out = existing.slice(0, insertAt) + block + rest;
91
149
  // loadUser import 보장 — gaonjs/config import 바로 뒤에 넣는다.
92
150
  if (!/from\s+['"]\.\/auth\.js['"]/.test(out)) {
93
151
  const im = out.match(/import\s+\{[^}]*\}\s+from\s+['"]gaonjs\/config['"][^\n]*\n/);
@@ -113,6 +171,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
113
171
  // 필요한 최소 세트를 먼저 보장한다 — 이미 있으면(gaon g ui-kit 를 먼저 돌린
114
172
  // 경우) skip, 없으면 생성. 이 보장이 없으면 스캐폴드 직후 페이지가 컴포넌트를
115
173
  // 해상하지 못해 vue-tsc 가 깨진다(gaon new → g auth 단독 경로 · 결정 60 게이트).
174
+ const includePublic = includesPublicRegistration(opts);
116
175
  const ui = writeUiKitFiles(root, authUiKitFiles(app));
117
176
  created.push(...ui.created);
118
177
  skipped.push(...ui.skipped);
@@ -124,7 +183,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
124
183
  // 결정 59: auth 배선이 없으면 로그인 후에도 currentUser 가 영구 null 이다.
125
184
  if (file.path === `apps/${app}/app.config.ts`) {
126
185
  const existing = readFileSync(abs, 'utf8');
127
- const patchedConfig = patchAppConfigAddAuth(existing);
186
+ const patchedConfig = patchAppConfigAddAuth(existing, app);
128
187
  if (patchedConfig) {
129
188
  writeFileSync(abs, patchedConfig, 'utf8');
130
189
  patched.push(file.path);
@@ -134,7 +193,8 @@ export function writeAuthScaffold(cwd, opts = {}) {
134
193
  skipped.push(file.path);
135
194
  warnings.push(`apps/${app}/app.config.ts 가 이미 있어 인증 배선을 자동 추가하지 못했습니다.\n` +
136
195
  `→ apps/${app}/app.config.ts 의 defineAppConfig({...}) 에 다음을 추가하세요:\n` +
137
- ` auth: { loadUser, loginRedirect: '/session/new' } // import { loadUser } from './auth.js'\n` +
196
+ ` session: { secret: process.env.${sessionSecretEnvFor(app)} ?? '32자 이상 비밀' },\n` +
197
+ ` auth: { loadUser, loginRedirect: '${urlPrefixFor(app)}/session/new' } // import { loadUser } from './auth.js'\n` +
138
198
  `→ 이 배선이 없으면 로그인해도 this.currentUser/requireAuth() 가 사용자를 받지 못합니다.`);
139
199
  }
140
200
  else {
@@ -154,7 +214,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
154
214
  const routesPath = join(root, 'apps', app, 'routes.ts');
155
215
  const routesRel = `apps/${app}/routes.ts`;
156
216
  if (existsSync(routesPath)) {
157
- const patchedContent = patchRoutes(readFileSync(routesPath, 'utf8'));
217
+ const patchedContent = patchRoutes(readFileSync(routesPath, 'utf8'), includePublic);
158
218
  if (patchedContent) {
159
219
  writeFileSync(routesPath, patchedContent, 'utf8');
160
220
  patched.push(routesRel);
@@ -165,18 +225,27 @@ export function writeAuthScaffold(cwd, opts = {}) {
165
225
  }
166
226
  else {
167
227
  mkdirSync(dirname(routesPath), { recursive: true });
168
- writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app), 'utf8');
228
+ writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app, includePublic), 'utf8');
169
229
  created.push(routesRel);
170
230
  }
231
+ // 결정 155: 시큐어 앱(공개 가입 미생성)은 역할 게이트 대시보드를 깔았다 — role 컬럼을
232
+ // 두라고 안내한다(§7.5.3 · authorize 예시가 실제 역할 규칙이 되도록).
233
+ if (!includePublic) {
234
+ warnings.push(`apps/${app}/ 는 시큐어 스캐폴드입니다(공개 회원가입 미생성 · 결정 155).\n` +
235
+ `→ 관리자는 직접 만들거나 승격하세요(공개 가입 라우트 없음). 공개 가입이 필요하면 --public 로 다시 생성.\n` +
236
+ `→ 역할 인가를 완성하려면 domain/schema/users.ts 에 role 컬럼을 추가하세요:\n` +
237
+ ` role: t.string().default('user'),\n` +
238
+ ` 그러면 apps/${app}/controllers/dashboard.ts 의 authorize(역할) 게이트가 실제 역할로 동작합니다.`);
239
+ }
171
240
  return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings };
172
241
  }
173
242
  /** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
174
243
  export function runGenerateAuthCommand(opts = {}) {
175
244
  const cwd = opts.cwd ?? process.cwd();
176
245
  const app = opts.app ?? 'web';
177
- const result = writeAuthScaffold(cwd, { app });
246
+ const result = writeAuthScaffold(cwd, { app, public: opts.public });
178
247
  if (opts.json) {
179
- process.stdout.write(JSON.stringify({ command: 'g auth', app, ...result }, null, 2) + '\n');
248
+ process.stdout.write(JSON.stringify({ command: 'g auth', app, public: opts.public ?? app === 'web', ...result }, null, 2) + '\n');
180
249
  return 0;
181
250
  }
182
251
  const lines = [''];
@@ -191,11 +260,13 @@ export function runGenerateAuthCommand(opts = {}) {
191
260
  for (const w of result.warnings)
192
261
  lines.push('', ` ⚠ ${w.split('\n').join('\n ')}`);
193
262
  lines.push('');
263
+ const prefix = urlPrefixFor(app);
264
+ const secretEnv = sessionSecretEnvFor(app);
194
265
  lines.push(' 다음 단계:');
195
- lines.push(' 1) .env 에 REDIS_URL·SESSION_SECRET(32자 이상)·COOKIE_SECRET 을 설정한다.');
266
+ lines.push(` 1) .env 에 REDIS_URL·${secretEnv}(32자 이상)·COOKIE_SECRET 을 설정한다.`);
196
267
  lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
197
268
  lines.push(' 3) gaon dev 로 실행한다 (.gaon 타입 브리지 생성 + 세션·인증 자동 배선).');
198
- lines.push(' → /registration/new 회원가입 · /session/new 로그인 · /dashboard 보호 페이지 (this.requireAuth()).');
269
+ lines.push(`${prefix}/registration/new 회원가입 · ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
199
270
  lines.push('');
200
271
  process.stdout.write(lines.join('\n') + '\n');
201
272
  return 0;
package/dist/index.d.ts CHANGED
@@ -4,6 +4,9 @@ export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
4
4
  export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, type DevConsole, type DevConsoleOptions, type DevSource, type DevLevel, type ComposeStatus, type ComposeUpOptions, type EnsureInfraResult, type DockerLocateOptions, type TscWatcherOptions, type TscWatcherHandle, type RestartWatcherOptions, type RestartWatcherHandle, } from "./dev/index.js";
5
5
  export { runCheckCommand, type CheckCommandOptions, type CheckStep, type CheckStepStatus, type CheckStepResult, } from "./commands/check.js";
6
6
  export { runGenCommand, regenerateProjectGaon, type GenCommandOptions, type GenResult, } from "./commands/gen.js";
7
+ export { runBuildCommand, type BuildCommandOptions, type BuildCommandResult, } from "./commands/build.js";
8
+ export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, type BuildAppOptions, type DistVerifyResult, } from "./dev/build.js";
9
+ export { generateMessagesDts } from "./messages-gen.js";
7
10
  export { runNewCommand, type NewCommandOptions, type NewCommandResult } from "./commands/new.js";
8
11
  export { runConsoleCommand, type ConsoleCommandOptions } from "./commands/console.js";
9
12
  export { runTestCommand, type TestCommandOptions, type TestScope } from "./commands/test.js";
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { MILESTONES, VERSION, HOMEPAGE, loadDotEnv } from "@gaonjs/core";
14
14
  import { runDevCommand } from "./commands/dev.js";
15
15
  import { runCheckCommand } from "./commands/check.js";
16
16
  import { runGenCommand } from "./commands/gen.js";
17
+ import { runBuildCommand } from "./commands/build.js";
17
18
  import { runNewCommand } from "./commands/new.js";
18
19
  import { runConsoleCommand } from "./commands/console.js";
19
20
  import { runTestCommand } from "./commands/test.js";
@@ -32,6 +33,9 @@ export { runDevCommand } from "./commands/dev.js";
32
33
  export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, } from "./dev/index.js";
33
34
  export { runCheckCommand, } from "./commands/check.js";
34
35
  export { runGenCommand, regenerateProjectGaon, } from "./commands/gen.js";
36
+ export { runBuildCommand, } from "./commands/build.js";
37
+ export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, } from "./dev/build.js";
38
+ export { generateMessagesDts } from "./messages-gen.js";
35
39
  export { runNewCommand } from "./commands/new.js";
36
40
  export { runConsoleCommand } from "./commands/console.js";
37
41
  export { runTestCommand } from "./commands/test.js";
@@ -100,8 +104,9 @@ function renderHelp(version = VERSION) {
100
104
  " gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
101
105
  " gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
102
106
  " gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
103
- " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
107
+ " gaon check typecheck · vue-tsc · build · doctor 통합 검사 (--only <step> · --no-doctor)",
104
108
  " gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
109
+ " gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
105
110
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
106
111
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
107
112
  " gaon doctor 정적 검사 (25 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계)",
@@ -220,8 +225,8 @@ export function runCli(argv, opts = {}) {
220
225
  return;
221
226
  }
222
227
  // `gaon check` — typecheck · vue-tsc · build (·doctor) 통합 검사(M9-G).
223
- // package.json 스크립트 관례 재사용. --only <step> 로 단일 단계, --include-doctor
224
- // doctor 포함. 종료 코드로 결과 전달.
228
+ // package.json 스크립트 관례 재사용. --only <step> 로 단일 단계. 결정 157: doctor
229
+ // 기본 포함(규칙 5 등이 check 도는 CI 에서 새지 않게) · --no-doctor 로만 뺀다.
225
230
  if (argv[0] === "check") {
226
231
  const knownSteps = ["typecheck", "vue-tsc", "build", "doctor"];
227
232
  const onlyIdx = argv.indexOf("--only");
@@ -232,7 +237,7 @@ export function runCli(argv, opts = {}) {
232
237
  void runCheckCommand({
233
238
  json: argv.includes("--json"),
234
239
  only,
235
- includeDoctor: argv.includes("--include-doctor"),
240
+ noDoctor: argv.includes("--no-doctor"),
236
241
  })
237
242
  .then((code) => {
238
243
  process.exitCode = code;
@@ -258,6 +263,20 @@ export function runCli(argv, opts = {}) {
258
263
  });
259
264
  return;
260
265
  }
266
+ // `gaon build` — 멀티 앱 프론트 프로덕션 빌드(결정 146). gaon gen 뒤 apps/* 를
267
+ // 순회해 앱마다 root/outDir/base 로 vite build. 스캐폴드 build 스크립트가 이걸 부른다.
268
+ if (argv[0] === "build") {
269
+ void runBuildCommand({ json: argv.includes("--json") })
270
+ .then((code) => {
271
+ process.exitCode = code;
272
+ })
273
+ .catch((err) => {
274
+ const msg = err instanceof Error ? err.message : String(err);
275
+ process.stderr.write(` ✗ gaon build 실패: ${msg}\n`);
276
+ process.exitCode = 1;
277
+ });
278
+ return;
279
+ }
261
280
  // `gaon doctor` — 정적 검사(M9-E · 17 검사). --check=<이름>[,<이름>...] 로
262
281
  // 선택 실행, --json 은 자동화 파싱용.
263
282
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
@@ -377,7 +396,8 @@ export function runCli(argv, opts = {}) {
377
396
  if (argv[1] === "auth") {
378
397
  const appIdx = argv.indexOf("--app");
379
398
  const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
380
- const code = runGenerateAuthCommand({ app, json: argv.includes("--json") });
399
+ // 결정 155: --public 비-web 앱도 공개 회원가입을 opt-in(공개 비-web 앱 탈출구).
400
+ const code = runGenerateAuthCommand({ app, json: argv.includes("--json"), public: argv.includes("--public") });
381
401
  process.exitCode = code;
382
402
  return;
383
403
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * locales/ 카탈로그에서 .gaon/messages.d.ts 를 생성한다. 카탈로그가 없거나 비어
3
+ * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
4
+ * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
5
+ */
6
+ export declare function generateMessagesDts(localesDir: string, out: string): boolean;
@@ -0,0 +1,24 @@
1
+ // @gaonjs/cli · .gaon/messages.d.ts 생성기 (결정 158 · 13차 W2)
2
+ //
3
+ // tables.d.ts·routes.d.ts 와 같은 .gaon 파이프라인의 세 번째 축(메시지). locales/
4
+ // 카탈로그의 키를 유니온 타입으로 물성화해, t('key') 의 존재하지 않는 키를 컴파일
5
+ // 타임에 잡는다(현재는 GaonMessages 가 비어 있어 키가 string 으로 열림). 생성 파일은
6
+ // 타입만 담는다(규칙 3). @gaonjs/i18n 의 공개 API(loadLocales·renderMessagesDts)만 쓴다.
7
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
8
+ import { dirname } from 'node:path';
9
+ import { loadLocales, renderMessagesDts } from '@gaonjs/i18n';
10
+ /**
11
+ * locales/ 카탈로그에서 .gaon/messages.d.ts 를 생성한다. 카탈로그가 없거나 비어
12
+ * 있으면 생성하지 않는다(GaonMessages 를 비운 채로 둬 t() 키가 string 폴백 — i18n 을
13
+ * 안 쓰는 프로젝트가 never 로 깨지지 않게). 생성 여부를 돌려준다.
14
+ */
15
+ export function generateMessagesDts(localesDir, out) {
16
+ if (!existsSync(localesDir))
17
+ return false;
18
+ const resources = loadLocales(localesDir);
19
+ if (Object.keys(resources).length === 0)
20
+ return false;
21
+ mkdirSync(dirname(out), { recursive: true });
22
+ writeFileSync(out, renderMessagesDts(resources), 'utf8');
23
+ return true;
24
+ }
@@ -72,9 +72,10 @@ export function appScaffoldFiles(name) {
72
72
  contents: [
73
73
  `// ${name} 앱 설정 — apps/${name}/app.config.ts.`,
74
74
  `// 잘 지은 앱의 app.config.ts 는 거의 비어 있다(v0.16 §1.1 ②).`,
75
+ `// 세션·인증 배선은 \`gaon g auth --app ${name}\` 이 여기에 채운다 — 직접 손대지 않는다.`,
76
+ `// 그 전에 상태 변경(POST 등) 라우트를 두면 doctor 가 세션/CSRF 누락을 잡는다(결정 140).`,
75
77
  `// 관례에서 벗어날 때만 값을 채운다:`,
76
78
  `// hosts: ['${name}.example.com'] // 도메인 바인딩(개발은 ${name}.localhost 자동)`,
77
- `// session: { cookie: '${name}_session' } // 세션 옵션 덮어쓰기`,
78
79
  `import { defineAppConfig } from 'gaonjs/config'`,
79
80
  ``,
80
81
  `export default defineAppConfig({})`,
@@ -13,10 +13,10 @@ import Button from '@shared/components/ui/Button.vue'
13
13
  // 이 페이지는 requireAuth 로 보호되므로 런타임엔 항상 로그인 상태(타입은 nullable).
14
14
  const shared = useShared()
15
15
 
16
- // 로그아웃 = DELETE /session (r.resource('session') 의 destroy).
16
+ // 로그아웃 = DELETE {{URL_PREFIX}}/session (r.resource('session') 의 destroy).
17
17
  // HTML <form> 은 DELETE 를 못 보내므로 Inertia 라우터로 실제 메서드를 보낸다(결정 64).
18
18
  function logout(): void {
19
- router.delete('/session', { headers: { 'x-csrf-token': shared.csrf } })
19
+ router.delete('{{URL_PREFIX}}/session', { headers: { 'x-csrf-token': shared.csrf } })
20
20
  }
21
21
  </script>
22
22
 
@@ -36,7 +36,7 @@ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
36
36
  <Alert v-if="props.error" variant="destructive" class="mb-4">
37
37
  <AlertDescription>{{ props.error }}</AlertDescription>
38
38
  </Alert>
39
- <Form @submit="form.post('/session')">
39
+ <Form @submit="form.post('{{URL_PREFIX}}/session')">
40
40
  <FormField label="이메일" :error="form.errors.email">
41
41
  <Input v-model="form.email" type="email" required />
42
42
  </FormField>
@@ -45,10 +45,7 @@ const form = useForm({ email: '', password: '', _csrf: shared.csrf })
45
45
  </FormField>
46
46
  <Button type="submit" class="w-full" :disabled="form.processing">로그인</Button>
47
47
  </Form>
48
- <p class="mt-4 text-center text-sm text-muted-foreground">
49
- 계정이 없으신가요?
50
- <Link href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</Link>
51
- </p>
48
+ {{SIGNUP_LINK}}
52
49
  </CardContent>
53
50
  </Card>
54
51
  </div>
@@ -33,7 +33,7 @@ const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
33
33
  <Alert v-if="props.error" variant="destructive" class="mb-4">
34
34
  <AlertDescription>{{ props.error }}</AlertDescription>
35
35
  </Alert>
36
- <Form @submit="form.post('/registration')">
36
+ <Form @submit="form.post('{{URL_PREFIX}}/registration')">
37
37
  <FormField label="이름" :error="form.errors.name">
38
38
  <Input v-model="form.name" required />
39
39
  </FormField>
@@ -47,7 +47,7 @@ const form = useForm({ name: '', email: '', password: '', _csrf: shared.csrf })
47
47
  </Form>
48
48
  <p class="mt-4 text-center text-sm text-muted-foreground">
49
49
  이미 계정이 있으신가요?
50
- <Link href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</Link>
50
+ <Link href="{{URL_PREFIX}}/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</Link>
51
51
  </p>
52
52
  </CardContent>
53
53
  </Card>
@@ -3,9 +3,9 @@ import { defineAppConfig } from 'gaonjs/config'
3
3
  import { loadUser } from './auth.js'
4
4
 
5
5
  export default defineAppConfig({
6
- // 세션 secret 은 32자 이상 — .env 의 SESSION_SECRET주입한다.
7
- session: { secret: process.env.SESSION_SECRET ?? 'dev-only-session-secret-change-me-now!!' },
6
+ // 세션 secret 은 32자 이상 — .env 의 {{SESSION_SECRET_ENV}}주입한다(앱별 세션 완전 분리).
7
+ session: { secret: process.env.{{SESSION_SECRET_ENV}} ?? 'dev-only-session-secret-{{APP_NAME}}-change-me!!' },
8
8
  // 결정 59: 세션 userId → 사용자 로드 배선. 이게 있어야 로그인 후
9
9
  // this.currentUser / this.requireAuth() 가 실제 사용자를 받는다.
10
- auth: { loadUser, loginRedirect: '/session/new' },
10
+ auth: { loadUser, loginRedirect: '{{URL_PREFIX}}/session/new' },
11
11
  })
@@ -2,7 +2,7 @@
2
2
  import { controller } from 'gaonjs/web'
3
3
 
4
4
  export default controller({
5
- // GET /dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
5
+ // GET {{URL_PREFIX}}/dashboard — 로그인해야 볼 수 있는 보호 페이지. 비로그인은 로그인으로 보내진다.
6
6
  async show() {
7
7
  // 보호만 하고 페이지 데이터는 넘기지 않는다 — 사용자·csrf 는 자동 주입되고
8
8
  // (결정 116), 페이지는 useShared() 로 읽는다. requireAuth 로 게이트만 건다.
@@ -0,0 +1,14 @@
1
+ // 보호 라우트 예시(관리 앱) — gaon g auth --app {{APP_NAME}}. 로그인 + 역할로 지킨다(§7 · 결정 145·155).
2
+ import { controller } from 'gaonjs/web'
3
+
4
+ export default controller({
5
+ // GET {{URL_PREFIX}}/dashboard — 로그인만으론 부족한 관리 화면. 역할(role)로 인가한다.
6
+ async show() {
7
+ const user = this.requireAuth() // 인증(401): 로그인 여부
8
+ // 인가(403): 로그인한 일반 사용자는 막는다. domain/schema/users.ts 에 role 컬럼을 두고
9
+ // (예: role: t.string().default('user')) 아래를 실제 역할 규칙으로 바꾸세요.
10
+ // 지금은 role !== 'admin' 이면 403 — 관리 앱에 로그인 고객이 들어오는 위험 기본을 막는다.
11
+ this.authorize((user as { role?: string }).role === 'admin')
12
+ return this.render('Dashboard', {})
13
+ },
14
+ })
@@ -3,11 +3,11 @@ import { controller, hashPassword } from 'gaonjs/web'
3
3
  import { User } from '../../../domain/models/User.js'
4
4
 
5
5
  export default controller({
6
- // GET /registration/new — 회원가입 폼. csrf 는 자동 주입된다(결정 116).
6
+ // GET {{URL_PREFIX}}/registration/new — 회원가입 폼. csrf 는 자동 주입된다(결정 116).
7
7
  async new() {
8
8
  return this.render('Auth/Signup', { error: null as string | null })
9
9
  },
10
- // POST /registration — 회원가입
10
+ // POST {{URL_PREFIX}}/registration — 회원가입
11
11
  async create() {
12
12
  const { name, email, password } = this.params({
13
13
  _row: {} as { name: string; email: string; password: string },
@@ -15,6 +15,6 @@ export default controller({
15
15
  const passwordDigest = await hashPassword(password)
16
16
  const user = await User.create({ name, email, passwordDigest })
17
17
  this.auth.login(user)
18
- return this.redirect('/dashboard')
18
+ return this.redirect('{{URL_PREFIX}}/dashboard')
19
19
  },
20
20
  })
@@ -3,26 +3,26 @@ import { controller, verifyPassword } from 'gaonjs/web'
3
3
  import { User } from '../../../domain/models/User.js'
4
4
 
5
5
  export default controller({
6
- // GET /session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
6
+ // GET {{URL_PREFIX}}/session/new — 로그인 폼. csrf 는 자동 주입되므로 넘기지 않는다(결정 116).
7
7
  async new() {
8
8
  return this.render('Auth/Login', { error: null as string | null })
9
9
  },
10
- // POST /session — 로그인
10
+ // POST {{URL_PREFIX}}/session — 로그인
11
11
  async create() {
12
12
  const { email, password } = this.params({ _row: {} as { email: string; password: string } })
13
13
  const user = await User.where('email', '=', email).first()
14
14
  // passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
15
15
  if (user && (await verifyPassword(password, user.passwordDigest))) {
16
16
  this.auth.login(user)
17
- return this.redirect('/dashboard')
17
+ return this.redirect('{{URL_PREFIX}}/dashboard')
18
18
  }
19
19
  return this.render('Auth/Login', {
20
20
  error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
21
21
  })
22
22
  },
23
- // DELETE /session — 로그아웃
23
+ // DELETE {{URL_PREFIX}}/session — 로그아웃
24
24
  async destroy() {
25
25
  this.auth.logout()
26
- return this.redirect('/session/new')
26
+ return this.redirect('{{URL_PREFIX}}/session/new')
27
27
  },
28
28
  })
@@ -18,6 +18,12 @@ STORAGE_BUCKET={{PROJECT_NAME}}
18
18
  STORAGE_ACCESS_KEY={{PROJECT_NAME}}
19
19
  STORAGE_SECRET_KEY={{PROJECT_NAME}}_secret
20
20
 
21
+ # 메일(§7 · M8) — docker-compose.yaml 의 mailpit 서비스와 정합(dev = MailPit sink · UI :8025).
22
+ # 운영은 실 SMTP 호스트·자격증명·SMTP_SECURE=true 로 교체한다.
23
+ SMTP_HOST=127.0.0.1
24
+ SMTP_PORT=1025
25
+ MAIL_FROM=no-reply@{{PROJECT_NAME}}.test
26
+
21
27
  # 세션 · 쿠키 서명 비밀 (32자 이상, 운영은 반드시 교체).
22
28
  SESSION_SECRET=change-me-to-a-32-char-random-secret!!
23
29
  COOKIE_SECRET=change-me-too-32-char-random-secret!!
@@ -27,6 +27,8 @@ v0.15+errata→v0.16→v0.17 · 결정 31~89)이며, 관례 문서는 **2층 구
27
27
  | 잡 · 이벤트 · 리스너 · 아웃박스 · 스케줄 | `agents/async.md` |
28
28
  | 채널 · 프레즌스 · 허브 | `agents/realtime.md` |
29
29
  | 파일 스토리지 (`Storage.put/url` · s3Disk · presigned · CSP 자동 배선) | `agents/storage.md` |
30
+ | 다국어 (`t()` · 카탈로그 · 요청별 로케일 · `this.setLocale` · 메시지 키 타입) | `agents/i18n.md` |
31
+ | 메일 (`mail()` · `deliver(data, { locale })` · MailPit · 발송 경로) | `agents/mail.md` |
30
32
  | 테스트 작성·실행 (실 인프라 · `expectJobProcessed`) | `agents/testing.md` |
31
33
  | 보안 기본값 · 탈출구(v-html · raw SQL) 사용 | `agents/security.md` |
32
34
  | 페이로드 봉인 (`@gaonjs/seal` · wire/문서/WS 암호화 · 선택 플러그인) | `agents/seal.md` |
@@ -191,7 +193,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
191
193
  작업마다 실행한다:
192
194
 
193
195
  ```bash
194
- gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
196
+ gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
195
197
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
196
198
  gaon doctor # 정적 검사 24종 (§2.2)
197
199
  ```
@@ -84,7 +84,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
84
84
  ## 3. 개발 검증 루프 (작업마다 실행)
85
85
 
86
86
  ```bash
87
- gaon check # .gaon 재생성 타입 검사 (CI 정합)
87
+ gaon check # .gaon 재생성 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
88
88
  gaon doctor # 정적 검사 24종 (상세 AGENTS §2.2)
89
89
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
90
90
  ```