@gaonjs/cli 0.33.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 (33) hide show
  1. package/dist/commands/check.d.ts +10 -1
  2. package/dist/commands/check.js +10 -6
  3. package/dist/commands/dev.js +2 -0
  4. package/dist/commands/gen.js +4 -2
  5. package/dist/db/projectData.d.ts +14 -0
  6. package/dist/db/projectData.js +51 -0
  7. package/dist/db.js +38 -2
  8. package/dist/dev.d.ts +11 -1
  9. package/dist/dev.js +29 -1
  10. package/dist/generate.d.ts +11 -2
  11. package/dist/generate.js +62 -33
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +7 -5
  14. package/dist/messages-gen.d.ts +6 -0
  15. package/dist/messages-gen.js +24 -0
  16. package/dist/templates/auth/Login.vue.tpl +1 -4
  17. package/dist/templates/auth/dashboard.secure.controller.ts.tpl +14 -0
  18. package/dist/templates/project/.env.example.tpl +6 -0
  19. package/dist/templates/project/AGENTS.md.tpl +3 -1
  20. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  21. package/dist/templates/project/agents/async.md.tpl +37 -0
  22. package/dist/templates/project/agents/data.md.tpl +47 -2
  23. package/dist/templates/project/agents/frontend.md.tpl +3 -1
  24. package/dist/templates/project/agents/i18n.md.tpl +107 -0
  25. package/dist/templates/project/agents/mail.md.tpl +92 -0
  26. package/dist/templates/project/agents/realtime.md.tpl +8 -0
  27. package/dist/templates/project/agents/security.md.tpl +36 -7
  28. package/dist/templates/project/agents/testing.md.tpl +6 -0
  29. package/dist/templates/project/agents/web.md.tpl +32 -0
  30. package/dist/templates/project/gaon.config.ts.tpl +14 -0
  31. package/package.json +7 -6
  32. package/dist/templates/auth/app.ts.tpl +0 -29
  33. package/dist/templates/auth/server.ts.tpl +0 -14
@@ -4,7 +4,16 @@ export interface CheckCommandOptions {
4
4
  readonly json?: boolean;
5
5
  /** 지정 시 그 검사 하나만 실행. 나머지는 스킵. */
6
6
  readonly only?: CheckStep;
7
- /** true 면 doctor 도 포함. 기본 false. */
7
+ /**
8
+ * 결정 157: doctor 는 이제 **기본 포함**이다(CI 가 check 하나만 돌려도 규칙 5 등
9
+ * doctor 규칙 위반을 잡는다). `noDoctor:true`(CLI `--no-doctor`)로 순수 typecheck/
10
+ * vue-tsc/build 만 돌린다.
11
+ */
12
+ readonly noDoctor?: boolean;
13
+ /**
14
+ * @deprecated 결정 157: doctor 가 기본이 돼 의미 없음(무시). `--include-doctor` 는
15
+ * 하위 호환 no-op — doctor 를 빼려면 `noDoctor`/`--no-doctor` 를 쓴다.
16
+ */
8
17
  readonly includeDoctor?: boolean;
9
18
  }
10
19
  export type CheckStepStatus = 'passed' | 'failed' | 'skipped';
@@ -7,16 +7,16 @@
7
7
  * (`node_modules/.bin/tsc --noEmit` · `node_modules/.bin/vue-tsc --noEmit`
8
8
  * · `pnpm -r build` → `npm run build`).
9
9
  *
10
- * The One Way — 하나의 명령이 3(선택 4) 검사를 순서대로 돌린다:
10
+ * The One Way — 하나의 명령이 4 검사를 순서대로 돌린다(결정 157 · doctor 기본 포함):
11
11
  * 1) typecheck (pnpm typecheck 또는 tsc --noEmit)
12
12
  * 2) vue-tsc (pnpm vue-tsc 또는 vue-tsc --noEmit)
13
13
  * 3) build (pnpm build)
14
- * (4) doctor (--include-doctor · 코어 재사용)
14
+ * 4) doctor (기본 포함 · 규칙 5 등 doctor 규칙 · --no-doctor · 코어 재사용)
15
15
  *
16
16
  * 옵션 최소:
17
17
  * --json 자동화 · 각 검사의 passed/failed/skipped
18
18
  * --only <name> 단일 검사만
19
- * --include-doctor check 안에서 doctor · 기본 off
19
+ * --no-doctor doctor 빼고 typecheck/vue-tsc/build (결정 157)
20
20
  *
21
21
  * exit code: 검사가 하나라도 실패하면 1, 그 외 0. 미리 검사할 대상이
22
22
  * 하나도 없으면(예: 스크립트도 없고 바이너리도 없음) 스킵으로 리포트 —
@@ -34,6 +34,7 @@ import { runDoctorCommand } from '../doctor.js';
34
34
  import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
35
35
  import { registerTsResolve } from '../tsResolve.js';
36
36
  import { listFrontendApps, verifyAppDist } from '../dev/build.js';
37
+ import { generateMessagesDts } from '../messages-gen.js';
37
38
  /**
38
39
  * 프로젝트 스크립트 존재 여부. pnpm/npm 어느 쪽이든 `scripts.<name>` 을
39
40
  * 정의해 두면 우선 사용한다.
@@ -233,7 +234,7 @@ async function runDoctorStep(cwd) {
233
234
  */
234
235
  async function regenerateGaon(cwd) {
235
236
  const layout = resolveDevLayout(cwd);
236
- if (!layout.schemaDir && layout.apps.length === 0) {
237
+ if (!layout.schemaDir && layout.apps.length === 0 && !layout.localesDir) {
237
238
  return { status: 'skipped', tables: false, apps: [] };
238
239
  }
239
240
  // 생성기는 사용자 스키마·컨트롤러 .ts 를 동적 import 한다. TS-for-ESM
@@ -244,6 +245,7 @@ async function regenerateGaon(cwd) {
244
245
  const result = await regenerateGaonOnce(layout, {
245
246
  regenerateTables: generateTablesDts,
246
247
  regenerateRoutes: generateRoutesDts,
248
+ regenerateMessages: generateMessagesDts,
247
249
  });
248
250
  return { status: 'done', tables: result.tables, apps: result.apps };
249
251
  }
@@ -269,7 +271,9 @@ export async function runCheckCommand(opts = {}) {
269
271
  const cwd = opts.cwd ?? process.cwd();
270
272
  const json = opts.json ?? false;
271
273
  const only = opts.only;
272
- const includeDoctor = opts.includeDoctor ?? false;
274
+ // 결정 157: doctor 기본 포함(규칙 5 등 doctor 규칙이 check 만 도는 CI 에서 새지 않게).
275
+ // --no-doctor 로만 뺀다. --include-doctor(구)는 이제 no-op(기본이 된 동작).
276
+ const runDoctor = !opts.noDoctor;
273
277
  // 규칙 3 — 검사는 최신 타입 브리지 위에서만 신뢰할 수 있다. 재생성이
274
278
  // 실패하면 stale .gaon 으로 검사하지 않고 즉시 중단한다.
275
279
  const regen = await regenerateGaon(cwd);
@@ -277,7 +281,7 @@ export async function runCheckCommand(opts = {}) {
277
281
  ? []
278
282
  : only
279
283
  ? [only]
280
- : ['typecheck', 'vue-tsc', 'build', ...(includeDoctor ? ['doctor'] : [])];
284
+ : ['typecheck', 'vue-tsc', 'build', ...(runDoctor ? ['doctor'] : [])];
281
285
  const results = [];
282
286
  for (const step of steps) {
283
287
  if (step === 'doctor') {
@@ -26,6 +26,7 @@ import { registerTsResolve } from '../tsResolve.js';
26
26
  import { startDev, resolveDevLayout } from '../dev.js';
27
27
  import { generateTablesDts, watchDir } from '@gaonjs/data';
28
28
  import { generateRoutesDts } from '@gaonjs/web';
29
+ import { generateMessagesDts } from '../messages-gen.js';
29
30
  import { createDevConsole } from '../dev/console.js';
30
31
  import { ensureInfra, composeDown } from '../dev/docker.js';
31
32
  import { startTscWatchers, killChild } from '../dev/tsc.js';
@@ -86,6 +87,7 @@ async function startGaonRegen(args) {
86
87
  layout,
87
88
  regenerateTables: generateTablesDts,
88
89
  regenerateRoutes: generateRoutesDts,
90
+ regenerateMessages: generateMessagesDts,
89
91
  watch: watchDir,
90
92
  log: (e) => {
91
93
  if (e.kind === 'ready') {
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { generateTablesDts } from '@gaonjs/data';
17
17
  import { generateRoutesDts } from '@gaonjs/web';
18
+ import { generateMessagesDts } from '../messages-gen.js';
18
19
  import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
19
20
  import { registerTsResolve } from '../tsResolve.js';
20
21
  /**
@@ -24,13 +25,14 @@ import { registerTsResolve } from '../tsResolve.js';
24
25
  */
25
26
  export async function regenerateProjectGaon(cwd) {
26
27
  const layout = resolveDevLayout(cwd);
27
- if (!layout.schemaDir && layout.apps.length === 0) {
28
- return { tables: false, apps: [], skipped: true };
28
+ if (!layout.schemaDir && layout.apps.length === 0 && !layout.localesDir) {
29
+ return { tables: false, apps: [], messages: false, skipped: true };
29
30
  }
30
31
  registerTsResolve();
31
32
  const result = await regenerateGaonOnce(layout, {
32
33
  regenerateTables: generateTablesDts,
33
34
  regenerateRoutes: generateRoutesDts,
35
+ regenerateMessages: generateMessagesDts,
34
36
  });
35
37
  return { ...result, skipped: false };
36
38
  }
@@ -0,0 +1,14 @@
1
+ import type { ConnectionConfig } from '@gaonjs/data';
2
+ /** 프로젝트(projDir) 관점에서 @gaonjs/data 가 해상되는 경로. 미설치면 null. */
3
+ export declare function resolveProjectDataPath(projDir: string): string | null;
4
+ /** 프로젝트 @gaonjs/data 가 CLI 것과 **다른 인스턴스**인가(전역 설치 이중 로드 신호). */
5
+ export declare function isSplitDataInstance(projDir: string): boolean;
6
+ /**
7
+ * 프로젝트의 @gaonjs/data 인스턴스가 CLI 것과 다르면(전역 설치) 그 인스턴스에 커넥션을
8
+ * 등록해 모델 레이어가 같은 레지스트리를 보게 한다. 같은 인스턴스(로컬)면 resolveDbTarget
9
+ * 이 이미 등록했으므로 **no-op(null 반환)** — 로컬 경로 무회귀. 반환 핸들의 close() 는
10
+ * 프로젝트 인스턴스 커넥션을 정리한다(다른 인스턴스일 때만).
11
+ */
12
+ export declare function registerInProjectData(projDir: string, dbKey: string, connCfg: ConnectionConfig): Promise<{
13
+ close(): Promise<void>;
14
+ } | null>;
@@ -0,0 +1,51 @@
1
+ // @gaonjs/cli · 프로젝트 로컬 @gaonjs/data 해상 (결정 156 · 전역-로컬 레지스트리 분열 근본 수정)
2
+ //
3
+ // 전역 설치 CLI(`npm i -g gaonjs`)는 자기 번들의 @gaonjs/data 를 로드하고, 프로젝트의
4
+ // domain 코드(seed 가 실행하는 모델)는 **프로젝트 node_modules** 의 @gaonjs/data 를
5
+ // 로드한다(tsResolve 훅이 bare 지정자를 안 건드림 · dual package hazard). 두 인스턴스는
6
+ // 커넥션 레지스트리가 갈려, resolveDbTarget 이 CLI 인스턴스에 등록한 커넥션을 모델
7
+ // 레이어(프로젝트 인스턴스)가 못 봐서 seed 가 "커넥션 'main' 미등록" 으로 죽었다.
8
+ // db migrate/diff/status 는 모델을 안 거쳐 이 분열을 가렸다(13차 실측).
9
+ //
10
+ // 근본 수정: seed 처럼 **프로젝트 모델 레이어를 거치는** 명령은, 프로젝트 인스턴스의
11
+ // @gaonjs/data 에도 커넥션을 등록해 모델과 **단일 레지스트리**를 공유한다. 로컬 설치
12
+ // (CLI·프로젝트가 같은 @gaonjs/data)면 두 경로가 같은 모듈이라 no-op — migrate/diff/
13
+ // status 를 비롯한 로컬 경로는 무회귀다.
14
+ import { createRequire } from 'node:module';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { join, resolve as resolvePath } from 'node:path';
17
+ /** CLI 자신이 로드한 @gaonjs/data 의 해상 경로. */
18
+ function cliDataPath() {
19
+ return createRequire(import.meta.url).resolve('@gaonjs/data');
20
+ }
21
+ /** 프로젝트(projDir) 관점에서 @gaonjs/data 가 해상되는 경로. 미설치면 null. */
22
+ export function resolveProjectDataPath(projDir) {
23
+ try {
24
+ // projDir/package.json 기준으로 해상 — 프로젝트 node_modules 의 @gaonjs/data.
25
+ const req = createRequire(join(resolvePath(projDir), 'package.json'));
26
+ return req.resolve('@gaonjs/data');
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** 프로젝트 @gaonjs/data 가 CLI 것과 **다른 인스턴스**인가(전역 설치 이중 로드 신호). */
33
+ export function isSplitDataInstance(projDir) {
34
+ const projPath = resolveProjectDataPath(projDir);
35
+ return projPath !== null && projPath !== cliDataPath();
36
+ }
37
+ /**
38
+ * 프로젝트의 @gaonjs/data 인스턴스가 CLI 것과 다르면(전역 설치) 그 인스턴스에 커넥션을
39
+ * 등록해 모델 레이어가 같은 레지스트리를 보게 한다. 같은 인스턴스(로컬)면 resolveDbTarget
40
+ * 이 이미 등록했으므로 **no-op(null 반환)** — 로컬 경로 무회귀. 반환 핸들의 close() 는
41
+ * 프로젝트 인스턴스 커넥션을 정리한다(다른 인스턴스일 때만).
42
+ */
43
+ export async function registerInProjectData(projDir, dbKey, connCfg) {
44
+ const projPath = resolveProjectDataPath(projDir);
45
+ if (!projPath || projPath === cliDataPath())
46
+ return null; // 로컬 설치 = 단일 인스턴스 · no-op
47
+ const mod = (await import(pathToFileURL(projPath).href));
48
+ const db = mod.createDb(connCfg);
49
+ mod.registerConnection(dbKey, db, connCfg.adapter);
50
+ return { close: () => mod.destroyAllConnections() };
51
+ }
package/dist/db.js CHANGED
@@ -12,6 +12,7 @@ import { pathToFileURL } from 'node:url';
12
12
  import { createDb, registerConnection, destroyAllConnections, isSeedDef, } from '@gaonjs/data';
13
13
  import { registerTsResolve } from './tsResolve.js';
14
14
  import { resolveDbTarget } from './db/resolve.js';
15
+ import { registerInProjectData, isSplitDataInstance } from './db/projectData.js';
15
16
  /** DB URL 에서 어댑터를 추정한다(gaon work 와 동일 규칙). */
16
17
  function dbConfigFromUrl(url) {
17
18
  if (url.startsWith('mysql://') || url.startsWith('mariadb://')) {
@@ -52,24 +53,59 @@ export async function runDbSeedCommand(opts = {}) {
52
53
  if (opts.databaseUrl) {
53
54
  const cfg = dbConfigFromUrl(opts.databaseUrl);
54
55
  registerConnection(dbKey, createDb(cfg), cfg.adapter);
56
+ // 결정 156: 전역 설치면 프로젝트 인스턴스에도 등록(모델과 단일 레지스트리 공유).
57
+ const projData = await registerInProjectData(root, dbKey, cfg);
55
58
  try {
56
59
  const def = await loadSeed(root);
57
- await def.run();
60
+ await runSeed(def, root);
58
61
  return { exitCode: 0, ...SEED_OK };
59
62
  }
60
63
  finally {
64
+ if (projData)
65
+ await projData.close();
61
66
  await destroyAllConnections();
62
67
  }
63
68
  }
64
69
  // 그 외 — migrate 와 같은 해석 경로: gaon.config.ts db.<키> → GAON_DATABASE_URL.
65
70
  // config 도 env 도 없으면 resolveDbTarget 이 수리 안내 에러를 던진다(§7.5.3).
66
71
  const target = await resolveDbTarget({ cwd: root, dbKey, configPath: opts.configPath });
72
+ // 결정 156: seed 는 모델 레이어를 거친다 — 전역 설치(CLI≠프로젝트 @gaonjs/data)면
73
+ // 프로젝트 인스턴스에도 커넥션을 등록해 "main 미등록" 분열을 근본에서 없앤다. 로컬
74
+ // 설치(단일 인스턴스)면 no-op. url 없는 config 는 seed 대상이 아니라 안내한다.
75
+ if (!target.url) {
76
+ await target.close();
77
+ throw new Error(`[gaon db seed] 커넥션 '${dbKey}' 에 url 이 없어 시드를 실행할 수 없습니다.\n` +
78
+ ` → gaon.config.ts 의 db.${dbKey} 에 url 을 두거나 .env 의 GAON_DATABASE_URL 을 설정하세요.`);
79
+ }
80
+ const projData = await registerInProjectData(root, dbKey, { adapter: target.adapter, url: target.url, poolMax: 1 });
67
81
  try {
68
82
  const def = await loadSeed(root);
69
- await def.run();
83
+ await runSeed(def, root);
70
84
  return { exitCode: 0, ...SEED_OK };
71
85
  }
72
86
  finally {
87
+ if (projData)
88
+ await projData.close();
73
89
  await target.close();
74
90
  }
75
91
  }
92
+ /**
93
+ * 시드를 실행하되, "커넥션 미등록" 계열 실패는 전역-로컬 @gaonjs/data 분열로 진단해
94
+ * 실제 수리(로컬 실행)로 안내한다(결정 156 · §7.5.3 — 이미 등록된 커넥션을 또 선언하라던
95
+ * 오진 제거).
96
+ */
97
+ async function runSeed(def, root) {
98
+ try {
99
+ await def.run();
100
+ }
101
+ catch (err) {
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ if (/등록되지 않았|not registered|미등록/.test(msg) && isSplitDataInstance(root)) {
104
+ throw new Error(`[gaon db seed] 시드 실행 중 커넥션을 찾지 못했습니다 — 전역 설치 CLI 와 프로젝트가\n` +
105
+ `@gaonjs/data 를 각각 로드해 커넥션 레지스트리가 갈렸습니다(dual package hazard).\n` +
106
+ ` → 프로젝트 로컬로 실행하세요: npx gaon db seed (또는 package.json 스크립트로 실행)\n` +
107
+ ` (원본: ${msg})`);
108
+ }
109
+ throw err;
110
+ }
111
+ }
package/dist/dev.d.ts CHANGED
@@ -11,11 +11,17 @@ export interface DevLayout {
11
11
  /** .gaon/tables.d.ts */
12
12
  readonly tablesOut: string;
13
13
  readonly apps: readonly DevApp[];
14
+ /** locales/ (없으면 undefined — messages 축 생략 · 결정 158 · W2). */
15
+ readonly localesDir?: string;
16
+ /** .gaon/messages.d.ts */
17
+ readonly messagesOut: string;
14
18
  }
15
19
  export interface DevDeps {
16
20
  readonly layout: DevLayout;
17
21
  regenerateTables(schemaDir: string, out: string): Promise<unknown>;
18
22
  regenerateRoutes(appDir: string, out: string): Promise<unknown>;
23
+ /** locales/ → .gaon/messages.d.ts (결정 158 · W2). i18n 축을 쓰는 호출자만 준다. */
24
+ regenerateMessages?(localesDir: string, out: string): unknown;
19
25
  watch(dir: string, opts: WatchOptions): WatchHandle;
20
26
  log(event: DevEvent): void;
21
27
  onError(err: Error): void;
@@ -26,7 +32,7 @@ export type DevEvent = {
26
32
  readonly apps: string[];
27
33
  } | {
28
34
  readonly kind: 'regen';
29
- readonly target: 'tables' | 'routes';
35
+ readonly target: 'tables' | 'routes' | 'messages';
30
36
  readonly app?: string;
31
37
  } | {
32
38
  readonly kind: 'stopped';
@@ -44,12 +50,16 @@ export declare function startDev(deps: DevDeps): Promise<DevHandle>;
44
50
  export interface RegenDeps {
45
51
  regenerateTables(schemaDir: string, out: string): Promise<unknown>;
46
52
  regenerateRoutes(appDir: string, out: string): Promise<unknown>;
53
+ /** locales/ → .gaon/messages.d.ts (결정 158 · W2). i18n 축을 쓰는 호출자만 준다. */
54
+ regenerateMessages?(localesDir: string, out: string): unknown;
47
55
  }
48
56
  export interface RegenResult {
49
57
  /** tables.d.ts 를 재생성했는가(domain/schema 존재 시에만). */
50
58
  readonly tables: boolean;
51
59
  /** routes.d.ts 를 재생성한 앱 이름. */
52
60
  readonly apps: readonly string[];
61
+ /** messages.d.ts 를 재생성했는가(locales/ 존재 · 결정 158 · W2). */
62
+ readonly messages: boolean;
53
63
  }
54
64
  /**
55
65
  * 워처 없이 .gaon 타입 브리지를 1회 전체 재생성한다. `gaon check` 처럼
package/dist/dev.js CHANGED
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import { readdirSync, existsSync } from 'node:fs';
15
15
  import { join, resolve } from 'node:path';
16
+ /** locales/*.json 변경(메시지 카탈로그) — messages.d.ts 재생성 트리거. */
17
+ const isMessagesChange = (f) => !f.includes('.gaon') && f.endsWith('.json');
16
18
  const isSchemaChange = (f) => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts') && !f.includes('.gaon');
17
19
  const isRoutesChange = (f) => !f.includes('.gaon') &&
18
20
  f.endsWith('.ts') &&
@@ -35,6 +37,11 @@ export async function startDev(deps) {
35
37
  await deps.regenerateRoutes(app.appDir, app.routesOut);
36
38
  deps.log({ kind: 'regen', target: 'routes', app: app.name });
37
39
  }
40
+ // 결정 158(W2): locales/ → messages.d.ts.
41
+ if (layout.localesDir && deps.regenerateMessages) {
42
+ await deps.regenerateMessages(layout.localesDir, layout.messagesOut);
43
+ deps.log({ kind: 'regen', target: 'messages' });
44
+ }
38
45
  // 2) 스키마 워치 → tables.d.ts 재생성.
39
46
  if (layout.schemaDir) {
40
47
  const schemaDir = layout.schemaDir;
@@ -58,6 +65,19 @@ export async function startDev(deps) {
58
65
  onError: deps.onError,
59
66
  }));
60
67
  }
68
+ // 4) locales 워치 → messages.d.ts 재생성(결정 158 · W2).
69
+ if (layout.localesDir && deps.regenerateMessages) {
70
+ const localesDir = layout.localesDir;
71
+ const regenerateMessages = deps.regenerateMessages;
72
+ handles.push(deps.watch(localesDir, {
73
+ filter: isMessagesChange,
74
+ onChange: async () => {
75
+ await regenerateMessages(localesDir, layout.messagesOut);
76
+ deps.log({ kind: 'regen', target: 'messages' });
77
+ },
78
+ onError: deps.onError,
79
+ }));
80
+ }
61
81
  deps.log({ kind: 'ready', schema: !!layout.schemaDir, apps: layout.apps.map((a) => a.name) });
62
82
  return {
63
83
  close() {
@@ -80,7 +100,12 @@ export async function regenerateGaonOnce(layout, deps) {
80
100
  for (const app of layout.apps) {
81
101
  await deps.regenerateRoutes(app.appDir, app.routesOut);
82
102
  }
83
- return { tables: !!layout.schemaDir, apps: layout.apps.map((a) => a.name) };
103
+ // 결정 158(W2): locales/ 있으면 messages.d.ts 도 재생성한다(i18n 타입 브리지).
104
+ let messages = false;
105
+ if (layout.localesDir && deps.regenerateMessages) {
106
+ messages = (await deps.regenerateMessages(layout.localesDir, layout.messagesOut)) === true;
107
+ }
108
+ return { tables: !!layout.schemaDir, apps: layout.apps.map((a) => a.name), messages };
84
109
  }
85
110
  /** cwd 관례로 프로젝트 레이아웃을 해석한다(존재하는 것만 포함). */
86
111
  export function resolveDevLayout(cwd) {
@@ -102,10 +127,13 @@ export function resolveDevLayout(cwd) {
102
127
  });
103
128
  }
104
129
  }
130
+ const localesDirPath = join(root, 'locales');
105
131
  return {
106
132
  schemaDir: existsSync(schemaDirPath) ? schemaDirPath : undefined,
107
133
  tablesOut: join(root, '.gaon', 'tables.d.ts'),
108
134
  apps: apps.sort((a, b) => a.name.localeCompare(b.name)),
135
+ localesDir: existsSync(localesDirPath) ? localesDirPath : undefined,
136
+ messagesOut: join(root, '.gaon', 'messages.d.ts'),
109
137
  };
110
138
  }
111
139
  // runDevCommand · DevCommandOptions 는 M9-C 에서 commands/dev.ts 로 이동.
@@ -1,6 +1,12 @@
1
1
  export interface AuthScaffoldOptions {
2
2
  /** 대상 앱(apps/<app>). 기본 'web'. */
3
3
  readonly app?: string;
4
+ /**
5
+ * 결정 155: 공개 회원가입(registration)을 깔지 여부. 기본은 **앱명 기반 시큐어**
6
+ * — web 은 공개 가입 O, 비-web(admin 등)은 공개 가입 X + 역할 게이트(authorize).
7
+ * `--public` 로 비-web 앱도 공개 가입을 opt-in 한다(공개 비-web 앱용 탈출구).
8
+ */
9
+ readonly public?: boolean;
4
10
  }
5
11
  /** 생성할 파일 하나 — 경로는 프로젝트 루트 기준. */
6
12
  export interface ScaffoldFile {
@@ -14,13 +20,14 @@ export interface ScaffoldResult {
14
20
  /** 손 수리가 필요한 지점 — 수리 안내 문장(§7.5.3)을 그대로 담는다. */
15
21
  readonly warnings: string[];
16
22
  }
17
- /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다. */
23
+ /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
24
+ * 결정 155: 공개 가입 여부(includePublic)로 registration·Signup·dashboard 변형을 가른다. */
18
25
  export declare function authScaffoldFiles(opts?: AuthScaffoldOptions): ScaffoldFile[];
19
26
  /**
20
27
  * 기존 routes.ts 에 세션·회원가입 리소스를 끼워 넣는다. 이미 있으면 null.
21
28
  * `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
22
29
  */
23
- export declare function patchRoutes(existing: string): string | null;
30
+ export declare function patchRoutes(existing: string, includePublic?: boolean): string | null;
24
31
  /**
25
32
  * 기존 app.config.ts 에 auth 배선을 끼워 넣는다 (결정 93 · gaon new 기본 web 앱은
26
33
  * 세션만 배선돼 있어 g auth 는 auth 만 추가하면 완성된다). 반환:
@@ -35,6 +42,8 @@ export interface GenerateAuthOptions {
35
42
  readonly cwd?: string;
36
43
  readonly app?: string;
37
44
  readonly json?: boolean;
45
+ /** 결정 155: 비-web 앱도 공개 회원가입을 opt-in(공개 비-web 앱용 탈출구). */
46
+ readonly public?: boolean;
38
47
  }
39
48
  /** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
40
49
  export declare function runGenerateAuthCommand(opts?: GenerateAuthOptions): number;
package/dist/generate.js CHANGED
@@ -16,6 +16,11 @@ 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');
@@ -42,46 +47,59 @@ function sessionSecretEnvFor(app) {
42
47
  function devSessionSecretFor(app) {
43
48
  return `dev-only-session-secret-${app}-change-me-now!!`;
44
49
  }
45
- /** 템플릿 파일을 읽어 토큰을 치환한다. {{APP_NAME}}·{{URL_PREFIX}}·
46
- * {{SESSION_SECRET_ENV}} Vue {{ }} 보간과 겹치지 않는 고정 리터럴이라
47
- * 단순 replaceAll 로 안전하다(결정 141). */
48
- function renderTemplate(name, app) {
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) {
49
62
  const raw = readFileSync(join(TEMPLATE_DIR, name), 'utf8');
50
63
  return raw
51
64
  .replaceAll('{{APP_NAME}}', app)
52
65
  .replaceAll('{{URL_PREFIX}}', urlPrefixFor(app))
53
- .replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app));
66
+ .replaceAll('{{SESSION_SECRET_ENV}}', sessionSecretEnvFor(app))
67
+ .replaceAll('{{SIGNUP_LINK}}', includePublic ? signupLinkMarkup(app) : '');
54
68
  }
55
- /** 템플릿 파일 생성 경로 매핑(라우트 제외 라우트는 패치로 처리). */
56
- const TEMPLATES = [
57
- { tpl: 'user.schema.ts.tpl', out: () => 'domain/schema/users.ts' },
58
- { tpl: 'user.model.ts.tpl', out: () => 'domain/models/User.ts' },
59
- { tpl: 'auth.wiring.ts.tpl', out: (a) => `apps/${a}/auth.ts` },
60
- { tpl: 'session.controller.ts.tpl', out: (a) => `apps/${a}/controllers/session.ts` },
61
- { tpl: 'registration.controller.ts.tpl', out: (a) => `apps/${a}/controllers/registration.ts` },
62
- { tpl: 'dashboard.controller.ts.tpl', out: (a) => `apps/${a}/controllers/dashboard.ts` },
63
- // 결정 32·46: Vue 페이지 경로 세그먼트는 PascalCase(Route 이름) — 'Auth/'.
64
- // examples/blog 정본과 page-filename doctor 규칙에 정합(소문자 'auth/' 는
65
- // doctor 가 error 로 잡던 스캐폴드 표류였다 · W10 실측).
66
- { tpl: 'Login.vue.tpl', out: (a) => `apps/${a}/pages/Auth/Login.vue` },
67
- { tpl: 'Signup.vue.tpl', out: (a) => `apps/${a}/pages/Auth/Signup.vue` },
68
- { tpl: 'Dashboard.vue.tpl', out: (a) => `apps/${a}/pages/Dashboard.vue` },
69
- // 결정 59: 세션·인증 배선은 app.config.ts — 표준 부팅(gaon dev/serve = wireGaon)이
70
- // 소비한다. 과거의 수동 부팅 스캐폴드(app.ts·server.ts)는 두 번째 부팅 경로를
71
- // 만들어 One Way 를 깨고, 표준 경로에는 auth 배선이 빠져 currentUser 가 영구
72
- // null 이 되는 파손을 남겼다 — 제거했다.
73
- { tpl: 'app.config.ts.tpl', out: (a) => `apps/${a}/app.config.ts` },
74
- ];
75
- /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다. */
69
+ /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
70
+ * 결정 155: 공개 가입 여부(includePublic)로 registration·Signup·dashboard 변형을 가른다. */
76
71
  export function authScaffoldFiles(opts = {}) {
77
72
  const app = opts.app ?? 'web';
78
- 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) }));
79
97
  }
80
98
  /**
81
99
  * 기존 routes.ts 에 세션·회원가입 리소스를 끼워 넣는다. 이미 있으면 null.
82
100
  * `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
83
101
  */
84
- export function patchRoutes(existing) {
102
+ export function patchRoutes(existing, includePublic = true) {
85
103
  if (existing.includes("resource('session')"))
86
104
  return null;
87
105
  const m = existing.match(/routes\(\s*\(\s*\w+\s*\)\s*=>\s*\{/);
@@ -91,9 +109,10 @@ export function patchRoutes(existing) {
91
109
  // dashboard 라우트도 함께 배선한다 — 없으면 스캐폴드 Dashboard.vue 의
92
110
  // pageProps<'…:dashboard#show'> 가 라우트 맵에서 해상되지 않아 vue-tsc 가
93
111
  // 깨진다(결정 60 클린룸 실측 — gaon new 프로젝트에 g auth 를 얹는 경로).
112
+ // 결정 155: 회원가입 리소스는 공개 가입 앱에만(시큐어 앱은 빼서 공개 가입 라우트 자체를 안 깐다).
94
113
  const inject = "\n r.get('/dashboard', 'dashboard#show') // 보호 페이지 (gaon g auth)" +
95
114
  "\n r.resource('session') // 로그인/로그아웃 (gaon g auth)" +
96
- "\n r.resource('registration') // 회원가입 (gaon g auth)";
115
+ (includePublic ? "\n r.resource('registration') // 회원가입 (gaon g auth)" : '');
97
116
  return existing.slice(0, insertAt) + inject + existing.slice(insertAt);
98
117
  }
99
118
  /**
@@ -152,6 +171,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
152
171
  // 필요한 최소 세트를 먼저 보장한다 — 이미 있으면(gaon g ui-kit 를 먼저 돌린
153
172
  // 경우) skip, 없으면 생성. 이 보장이 없으면 스캐폴드 직후 페이지가 컴포넌트를
154
173
  // 해상하지 못해 vue-tsc 가 깨진다(gaon new → g auth 단독 경로 · 결정 60 게이트).
174
+ const includePublic = includesPublicRegistration(opts);
155
175
  const ui = writeUiKitFiles(root, authUiKitFiles(app));
156
176
  created.push(...ui.created);
157
177
  skipped.push(...ui.skipped);
@@ -194,7 +214,7 @@ export function writeAuthScaffold(cwd, opts = {}) {
194
214
  const routesPath = join(root, 'apps', app, 'routes.ts');
195
215
  const routesRel = `apps/${app}/routes.ts`;
196
216
  if (existsSync(routesPath)) {
197
- const patchedContent = patchRoutes(readFileSync(routesPath, 'utf8'));
217
+ const patchedContent = patchRoutes(readFileSync(routesPath, 'utf8'), includePublic);
198
218
  if (patchedContent) {
199
219
  writeFileSync(routesPath, patchedContent, 'utf8');
200
220
  patched.push(routesRel);
@@ -205,18 +225,27 @@ export function writeAuthScaffold(cwd, opts = {}) {
205
225
  }
206
226
  else {
207
227
  mkdirSync(dirname(routesPath), { recursive: true });
208
- writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app), 'utf8');
228
+ writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app, includePublic), 'utf8');
209
229
  created.push(routesRel);
210
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
+ }
211
240
  return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort(), warnings };
212
241
  }
213
242
  /** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
214
243
  export function runGenerateAuthCommand(opts = {}) {
215
244
  const cwd = opts.cwd ?? process.cwd();
216
245
  const app = opts.app ?? 'web';
217
- const result = writeAuthScaffold(cwd, { app });
246
+ const result = writeAuthScaffold(cwd, { app, public: opts.public });
218
247
  if (opts.json) {
219
- 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');
220
249
  return 0;
221
250
  }
222
251
  const lines = [''];
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { runCheckCommand, type CheckCommandOptions, type CheckStep, type CheckSt
6
6
  export { runGenCommand, regenerateProjectGaon, type GenCommandOptions, type GenResult, } from "./commands/gen.js";
7
7
  export { runBuildCommand, type BuildCommandOptions, type BuildCommandResult, } from "./commands/build.js";
8
8
  export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, type BuildAppOptions, type DistVerifyResult, } from "./dev/build.js";
9
+ export { generateMessagesDts } from "./messages-gen.js";
9
10
  export { runNewCommand, type NewCommandOptions, type NewCommandResult } from "./commands/new.js";
10
11
  export { runConsoleCommand, type ConsoleCommandOptions } from "./commands/console.js";
11
12
  export { runTestCommand, type TestCommandOptions, type TestScope } from "./commands/test.js";
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ export { runCheckCommand, } from "./commands/check.js";
35
35
  export { runGenCommand, regenerateProjectGaon, } from "./commands/gen.js";
36
36
  export { runBuildCommand, } from "./commands/build.js";
37
37
  export { buildApp, buildAllApps, listFrontendApps, verifyAppDist, appBase, } from "./dev/build.js";
38
+ export { generateMessagesDts } from "./messages-gen.js";
38
39
  export { runNewCommand } from "./commands/new.js";
39
40
  export { runConsoleCommand } from "./commands/console.js";
40
41
  export { runTestCommand } from "./commands/test.js";
@@ -103,7 +104,7 @@ function renderHelp(version = VERSION) {
103
104
  " gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
104
105
  " gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
105
106
  " gaon serve --workers <n|auto> node:cluster 워커 다중화 (env WEB_CONCURRENCY · 기본 1)",
106
- " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
107
+ " gaon check typecheck · vue-tsc · build · doctor 통합 검사 (--only <step> · --no-doctor)",
107
108
  " gaon gen .gaon 타입 브리지 + api() 런타임 매니페스트만 재생성 (서버·검사 없이 · build 전제 · --json)",
108
109
  " gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
109
110
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
@@ -224,8 +225,8 @@ export function runCli(argv, opts = {}) {
224
225
  return;
225
226
  }
226
227
  // `gaon check` — typecheck · vue-tsc · build (·doctor) 통합 검사(M9-G).
227
- // package.json 스크립트 관례 재사용. --only <step> 로 단일 단계, --include-doctor
228
- // doctor 포함. 종료 코드로 결과 전달.
228
+ // package.json 스크립트 관례 재사용. --only <step> 로 단일 단계. 결정 157: doctor
229
+ // 기본 포함(규칙 5 등이 check 도는 CI 에서 새지 않게) · --no-doctor 로만 뺀다.
229
230
  if (argv[0] === "check") {
230
231
  const knownSteps = ["typecheck", "vue-tsc", "build", "doctor"];
231
232
  const onlyIdx = argv.indexOf("--only");
@@ -236,7 +237,7 @@ export function runCli(argv, opts = {}) {
236
237
  void runCheckCommand({
237
238
  json: argv.includes("--json"),
238
239
  only,
239
- includeDoctor: argv.includes("--include-doctor"),
240
+ noDoctor: argv.includes("--no-doctor"),
240
241
  })
241
242
  .then((code) => {
242
243
  process.exitCode = code;
@@ -395,7 +396,8 @@ export function runCli(argv, opts = {}) {
395
396
  if (argv[1] === "auth") {
396
397
  const appIdx = argv.indexOf("--app");
397
398
  const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
398
- 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") });
399
401
  process.exitCode = code;
400
402
  return;
401
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;