@gaonjs/cli 0.13.2 → 0.15.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.
@@ -36,6 +36,8 @@ export interface DevCommandOptions {
36
36
  readonly noVueTsc?: boolean;
37
37
  /** Docker 자동 기동 끄기(개별 debug · 사용자가 이미 띄운 경우). */
38
38
  readonly noDocker?: boolean;
39
+ /** 프론트 watch 빌드(vite build --watch) 끄기(개별 debug). */
40
+ readonly noVite?: boolean;
39
41
  /** 통합 콘솔 타임스탬프 표시. */
40
42
  readonly timestamp?: boolean;
41
43
  /** 시그널(테스트 주입 · 기본 process). */
@@ -30,6 +30,7 @@ import { createDevConsole } from '../dev/console.js';
30
30
  import { ensureInfra, composeDown } from '../dev/docker.js';
31
31
  import { startTscWatchers, killChild } from '../dev/tsc.js';
32
32
  import { startRestartWatcher } from '../dev/watcher.js';
33
+ import { startFrontendBuild } from '../dev/frontend-build.js';
33
34
  /**
34
35
  * gaon 셀프 경로를 찾는다. 부모가 gaon 으로 실행됐다면 argv[1] 이
35
36
  * gaonjs/dist/cli.js (또는 개발 시 packages/gaonjs/src/cli.ts). 자식
@@ -53,7 +54,9 @@ function resolveSelfCliEntry() {
53
54
  */
54
55
  function spawnServe(args) {
55
56
  const { node, args: nodeArgs } = resolveSelfCliEntry();
56
- const serveArgs = ['serve'];
57
+ // --dev: dev 전용 진단 라우트(/_gaon/health)를 켠다(결정 69). gaon dev 의
58
+ // serve 자식만 이 플래그를 받으므로 운영 serve 에는 진단이 노출되지 않는다.
59
+ const serveArgs = ['serve', '--dev'];
57
60
  if (args.json)
58
61
  serveArgs.push('--json');
59
62
  if (args.port != null)
@@ -148,6 +151,15 @@ export async function runDevCommand(opts = {}) {
148
151
  }
149
152
  // ── 2) .gaon 재생성 워처 ─────────────────────────────────────────
150
153
  const regen = await startGaonRegen({ cwd, console: consoleOut });
154
+ // ── 2') 프론트 번들 watch 빌드(결정 67) ─────────────────────────
155
+ // serve 가 dist/<앱>/index.html 을 문서 셸로 읽으므로 dev 에서 dist 를
156
+ // 최신으로 유지한다. 프론트 없는 프로젝트면 no-op.
157
+ const frontend = opts.noVite
158
+ ? undefined
159
+ : startFrontendBuild({
160
+ cwd,
161
+ onLog: (line, level) => consoleOut.log('vite', line, level),
162
+ });
151
163
  // ── 3) tsc / vue-tsc 워치 ────────────────────────────────────────
152
164
  const tsc = opts.noTsc && opts.noVueTsc
153
165
  ? undefined
@@ -243,6 +255,7 @@ export async function runDevCommand(opts = {}) {
243
255
  if (tsc) {
244
256
  await tsc.stop();
245
257
  }
258
+ frontend?.close();
246
259
  regen.close();
247
260
  if (stopDocker && composeFile) {
248
261
  consoleOut.log('docker', '--stop-docker · compose down 실행');
@@ -13,7 +13,7 @@
13
13
  * 정책상 chalk 는 쓰지 않는다 — 필요한 코드만 직접 쓴다.
14
14
  */
15
15
  /** 콘솔이 구분하는 로그 소스. */
16
- export type DevSource = 'dev' | 'docker' | 'serve' | 'watcher' | 'tsc' | 'vue-tsc';
16
+ export type DevSource = 'dev' | 'docker' | 'serve' | 'watcher' | 'tsc' | 'vue-tsc' | 'vite';
17
17
  /** 로그 레벨 — human 은 색상 강조, json 은 필드로 실린다. */
18
18
  export type DevLevel = 'info' | 'warn' | 'error';
19
19
  export interface DevConsoleOptions {
@@ -26,6 +26,7 @@ const SOURCE_COLOR = {
26
26
  watcher: '\x1b[36m', // cyan — 파일 감시
27
27
  tsc: '\x1b[33m', // yellow — 타입 검사
28
28
  'vue-tsc': '\x1b[95m', // bright magenta — vue 전용
29
+ vite: '\x1b[92m', // bright green — 프론트 빌드
29
30
  };
30
31
  /** stdout.isTTY 여부 자동 감지(테스트에서는 stream.isTTY 를 봄). */
31
32
  function detectTty(stream) {
@@ -0,0 +1,14 @@
1
+ export interface FrontendBuildHandle {
2
+ /** watch 빌드 프로세스를 종료한다. */
3
+ close(): void;
4
+ }
5
+ export interface StartFrontendBuildOptions {
6
+ readonly cwd: string;
7
+ /** 통합 콘솔 로그 훅. */
8
+ readonly onLog: (line: string, level: 'info' | 'warn' | 'error') => void;
9
+ }
10
+ /**
11
+ * vite build --watch 를 띄운다. 프론트가 없거나 vite 가 없으면 아무 것도 하지
12
+ * 않고 no-op 핸들을 돌려준다(dev 는 계속 진행).
13
+ */
14
+ export declare function startFrontendBuild(opts: StartFrontendBuildOptions): FrontendBuildHandle;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @gaonjs/cli · dev/frontend-build — 프론트 번들 watch 빌드 (결정 67 · M9-C)
3
+ *
4
+ * `gaon dev` 는 서버(serve)를 재시작 방식으로 띄운다(§dev.ts). 프론트 번들은
5
+ * serve 가 dist/<앱>/index.html 을 매 요청 읽어 문서 셸에 이어 붙이므로
6
+ * (packages/web frontend.ts), dev 에서 그 dist 를 최신으로 유지하려면 vite 를
7
+ * watch 모드로 함께 돌린다. 진짜 HMR(모듈 교체)은 v1 어댑터 범위 밖이라(§6.4)
8
+ * "저장 → 재빌드 → 새로고침" 루프를 제공한다.
9
+ *
10
+ * fail-open: apps/web/index.html·vite 가 없으면 조용히 건너뛴다(프론트 없는
11
+ * API 전용 프로젝트도 gaon dev 가 동작해야 한다). vite 실행 실패는 통합
12
+ * 콘솔에 알리되 dev 전체를 막지 않는다.
13
+ */
14
+ import { spawn } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+ /** 프로젝트에 프론트 진입 HTML(apps/web/index.html)이 있는가. */
18
+ function hasFrontend(cwd) {
19
+ return existsSync(join(cwd, 'apps', 'web', 'index.html'));
20
+ }
21
+ /** cwd 의 로컬 vite bin 을 찾는다(없으면 undefined — 건너뜀). */
22
+ function resolveViteBin(cwd) {
23
+ const bin = join(cwd, 'node_modules', '.bin', 'vite');
24
+ return existsSync(bin) ? bin : undefined;
25
+ }
26
+ /**
27
+ * vite build --watch 를 띄운다. 프론트가 없거나 vite 가 없으면 아무 것도 하지
28
+ * 않고 no-op 핸들을 돌려준다(dev 는 계속 진행).
29
+ */
30
+ export function startFrontendBuild(opts) {
31
+ const cwd = resolve(opts.cwd);
32
+ if (!hasFrontend(cwd)) {
33
+ opts.onLog('프론트 진입 HTML(apps/web/index.html)이 없어 vite 빌드를 건너뜁니다.', 'info');
34
+ return { close() { } };
35
+ }
36
+ const bin = resolveViteBin(cwd);
37
+ if (!bin) {
38
+ opts.onLog('vite 를 찾지 못해 프론트 watch 빌드를 건너뜁니다 → pnpm add -D vite @vitejs/plugin-vue', 'warn');
39
+ return { close() { } };
40
+ }
41
+ const child = spawn(bin, ['build', '--watch'], {
42
+ cwd,
43
+ stdio: ['ignore', 'pipe', 'pipe'],
44
+ env: { ...process.env },
45
+ });
46
+ child.stdout?.on('data', (b) => opts.onLog(b.toString().trimEnd(), 'info'));
47
+ child.stderr?.on('data', (b) => opts.onLog(b.toString().trimEnd(), 'error'));
48
+ child.on('error', (err) => opts.onLog(`vite 실행 실패: ${err.message}`, 'error'));
49
+ opts.onLog('▶ vite build --watch — 프론트 번들 감시 (dist/web)', 'info');
50
+ return {
51
+ close() {
52
+ if (child.exitCode === null && child.signalCode === null)
53
+ child.kill('SIGTERM');
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,72 @@
1
+ import { type GaonNats, type StreamStat } from '@gaonjs/async';
2
+ import type { GaonConfig } from '@gaonjs/config';
3
+ /** registerDevHealth 가 프로브에 쓰는 컨텍스트(serve 가 채운다). */
4
+ export interface DevHealthContext {
5
+ readonly cwd: string;
6
+ readonly config: GaonConfig;
7
+ readonly apps: readonly {
8
+ readonly name: string;
9
+ readonly dir: string;
10
+ }[];
11
+ readonly nats?: GaonNats;
12
+ readonly port: number;
13
+ readonly host: string;
14
+ }
15
+ export interface WebHealth {
16
+ readonly host: string;
17
+ readonly port: number;
18
+ readonly node: string;
19
+ readonly gaonjs: string;
20
+ readonly apps: readonly {
21
+ readonly name: string;
22
+ readonly prefix: string;
23
+ }[];
24
+ }
25
+ export type DatabaseHealth = {
26
+ readonly configured: false;
27
+ } | {
28
+ readonly configured: true;
29
+ readonly connected: boolean;
30
+ readonly adapter?: string;
31
+ readonly tables?: number;
32
+ readonly tableNames?: readonly string[];
33
+ readonly migrations?: number;
34
+ readonly error?: string;
35
+ };
36
+ export type HubHealth = {
37
+ readonly configured: false;
38
+ } | {
39
+ readonly configured: true;
40
+ readonly connected: boolean;
41
+ readonly streams?: readonly StreamStat[];
42
+ readonly error?: string;
43
+ };
44
+ export interface DoctorHealth {
45
+ readonly checks: number;
46
+ readonly passed: number;
47
+ readonly warnings: number;
48
+ readonly errors: number;
49
+ readonly level: 'pass' | 'warn' | 'error';
50
+ /** 사전 검사 실패(프로젝트 아님 등)면 코드. */
51
+ readonly fatal?: string;
52
+ }
53
+ export interface GaonHealth {
54
+ readonly ok: boolean;
55
+ readonly env: string;
56
+ readonly web: WebHealth;
57
+ readonly database: DatabaseHealth;
58
+ readonly hub: HubHealth;
59
+ readonly doctor: DoctorHealth;
60
+ /** 랜딩 코드 블록용 — 실 routes.ts 원문(dev only). */
61
+ readonly routes: {
62
+ readonly path: string;
63
+ readonly source: string;
64
+ } | null;
65
+ }
66
+ /**
67
+ * 라이브 헬스 스냅샷을 계산한다. 순수 조회(부작용 없음) — 각 프로브는 독립
68
+ * try/catch 라 하나가 실패해도 전체가 죽지 않는다.
69
+ */
70
+ export declare function computeHealth(ctx: DevHealthContext): Promise<GaonHealth>;
71
+ /** dev 전용 진단 라우트 경로. serve(--dev)가 이 경로에 computeHealth 를 건다. */
72
+ export declare const DEV_HEALTH_PATH = "/_gaon/health";
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @gaonjs/cli · dev 전용 라이브 헬스 엔드포인트 (결정 69 · W10)
3
+ *
4
+ * `GET /_gaon/health` — 방금 `gaon new` 로 만든 앱이 "실제로 살아 있는지" 를
5
+ * 랜딩(Home/Index.vue)이 라이브로 보여주기 위한 dev 전용 진단 소스다.
6
+ *
7
+ * dev-only by construction:
8
+ * `gaon dev` 는 `gaon serve` 를 자식으로 띄우고(commands/dev.ts), 그때
9
+ * serve 에 `--dev` 를 넘긴다. serve 는 `--dev` 일 때만 이 라우트를 등록한다
10
+ * (serve.ts). 운영 `gaon serve`(플래그 없음)는 등록조차 하지 않으므로
11
+ * production 에 노출되지 않는다(§CLAUDE.md 6 · handover-w10 §3.5·§5).
12
+ *
13
+ * 층 배치: 이 모듈은 doctor(computeDoctorResult)·data introspection·async
14
+ * 스트림 통계를 import 하므로 **cli 층**에 둔다. web/config 에 두면
15
+ * cli→web/config→cli 역방향 사이클이 된다(handover-w10 §5-3).
16
+ *
17
+ * 모든 프로브는 개별 try/catch — 하나가 실패해도 나머지 카드는 채운다.
18
+ * 조회 실패를 0/true 로 가장하지 않는다(결정 60 · 과대 약속 금지).
19
+ */
20
+ import { readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { VERSION } from '@gaonjs/core';
23
+ import { getConnection, hasConnection, snapshotFromDb, postgresDialect, MIGRATIONS_TABLE, } from '@gaonjs/data';
24
+ import { streamStats } from '@gaonjs/async';
25
+ import { computeDoctorResult } from '../doctor.js';
26
+ /** web 앱은 관례상 프리픽스 '/', 그 외는 '/<앱>'(dispatch.prefixFor 와 정합). */
27
+ function prefixFor(name) {
28
+ return name === 'web' ? '/' : `/${name}`;
29
+ }
30
+ async function probeDatabase(config) {
31
+ const main = config.db?.main;
32
+ if (!main)
33
+ return { configured: false };
34
+ const adapter = main.adapter;
35
+ try {
36
+ if (!hasConnection('main')) {
37
+ return { configured: true, connected: false, adapter, error: '커넥션 미등록' };
38
+ }
39
+ const db = getConnection('main');
40
+ // introspect 자체가 실 쿼리 — DB 가 죽어 있으면 여기서 throw → connected:false.
41
+ const snap = await snapshotFromDb(db, postgresDialect);
42
+ const tableNames = Object.keys(snap)
43
+ .filter((t) => t !== MIGRATIONS_TABLE)
44
+ .sort();
45
+ let migrations = 0;
46
+ try {
47
+ const row = await db
48
+ .selectFrom(MIGRATIONS_TABLE)
49
+ .select((eb) => eb.fn.countAll().as('c'))
50
+ .executeTakeFirst();
51
+ migrations = Number(row?.c ?? 0);
52
+ }
53
+ catch {
54
+ // _gaon_migrations 미생성(마이그레이션 이력 없음) — 0.
55
+ migrations = 0;
56
+ }
57
+ return {
58
+ configured: true,
59
+ connected: true,
60
+ adapter,
61
+ tables: tableNames.length,
62
+ tableNames,
63
+ migrations,
64
+ };
65
+ }
66
+ catch (err) {
67
+ return {
68
+ configured: true,
69
+ connected: false,
70
+ adapter,
71
+ error: err instanceof Error ? err.message : String(err),
72
+ };
73
+ }
74
+ }
75
+ async function probeHub(config, nats) {
76
+ if (!config.nats)
77
+ return { configured: false };
78
+ if (!nats)
79
+ return { configured: true, connected: false };
80
+ const connected = !nats.nc.isClosed();
81
+ if (!connected)
82
+ return { configured: true, connected: false };
83
+ try {
84
+ const streams = await streamStats(nats);
85
+ return { configured: true, connected: true, streams };
86
+ }
87
+ catch (err) {
88
+ return {
89
+ configured: true,
90
+ connected: true,
91
+ error: err instanceof Error ? err.message : String(err),
92
+ };
93
+ }
94
+ }
95
+ async function probeDoctor(cwd) {
96
+ try {
97
+ const result = await computeDoctorResult({ cwd });
98
+ if (result.fatal) {
99
+ return { checks: 0, passed: 0, warnings: 0, errors: 0, level: 'error', fatal: result.fatal.code };
100
+ }
101
+ const passed = result.passed.length;
102
+ const warnings = result.warnings.length;
103
+ const errors = result.errors.length;
104
+ const level = errors > 0 ? 'error' : warnings > 0 ? 'warn' : 'pass';
105
+ return { checks: passed + warnings + errors, passed, warnings, errors, level };
106
+ }
107
+ catch (err) {
108
+ return {
109
+ checks: 0,
110
+ passed: 0,
111
+ warnings: 0,
112
+ errors: 0,
113
+ level: 'error',
114
+ fatal: err instanceof Error ? err.message : String(err),
115
+ };
116
+ }
117
+ }
118
+ /** 랜딩 코드 블록용 routes.ts 원문. web 앱 우선, 없으면 첫 앱. 실패는 null. */
119
+ function readRoutesSource(ctx) {
120
+ const web = ctx.apps.find((a) => a.name === 'web') ?? ctx.apps[0];
121
+ if (!web)
122
+ return null;
123
+ const path = join(web.dir, 'routes.ts');
124
+ try {
125
+ return { path: `apps/${web.name}/routes.ts`, source: readFileSync(path, 'utf8') };
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ /**
132
+ * 라이브 헬스 스냅샷을 계산한다. 순수 조회(부작용 없음) — 각 프로브는 독립
133
+ * try/catch 라 하나가 실패해도 전체가 죽지 않는다.
134
+ */
135
+ export async function computeHealth(ctx) {
136
+ const [database, hub, doctor] = await Promise.all([
137
+ probeDatabase(ctx.config),
138
+ probeHub(ctx.config, ctx.nats),
139
+ probeDoctor(ctx.cwd),
140
+ ]);
141
+ const web = {
142
+ host: ctx.host,
143
+ port: ctx.port,
144
+ node: process.version,
145
+ gaonjs: VERSION,
146
+ apps: ctx.apps.map((a) => ({ name: a.name, prefix: prefixFor(a.name) })),
147
+ };
148
+ const ok = doctor.level !== 'error' &&
149
+ (database.configured === false || database.connected) &&
150
+ (hub.configured === false || hub.connected);
151
+ return { ok, env: process.env.NODE_ENV ?? 'development', web, database, hub, doctor, routes: readRoutesSource(ctx) };
152
+ }
153
+ /** dev 전용 진단 라우트 경로. serve(--dev)가 이 경로에 computeHealth 를 건다. */
154
+ export const DEV_HEALTH_PATH = '/_gaon/health';
@@ -12,5 +12,7 @@ export { startTscWatchers, killChild } from './tsc.js';
12
12
  export type { TscWatcherOptions, TscWatcherHandle } from './tsc.js';
13
13
  export { startRestartWatcher, isRestartChange, resolveWatchRoots } from './watcher.js';
14
14
  export type { RestartWatcherOptions, RestartWatcherHandle } from './watcher.js';
15
+ export { startFrontendBuild } from './frontend-build.js';
16
+ export type { FrontendBuildHandle, StartFrontendBuildOptions } from './frontend-build.js';
15
17
  export { createGaonViteServer } from './vite.js';
16
18
  export type { GaonViteServer, CreateGaonViteServerOptions } from './vite.js';
package/dist/dev/index.js CHANGED
@@ -8,5 +8,7 @@ export { createDevConsole } from './console.js';
8
8
  export { findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, } from './docker.js';
9
9
  export { startTscWatchers, killChild } from './tsc.js';
10
10
  export { startRestartWatcher, isRestartChange, resolveWatchRoots } from './watcher.js';
11
+ // 프론트 번들 watch 빌드 (결정 67)
12
+ export { startFrontendBuild } from './frontend-build.js';
11
13
  // Vite dev server 프로그램적 기동 (M3-runtime) — dev.ts 배선은 M1 이 통합.
12
14
  export { createGaonViteServer } from './vite.js';
package/dist/doctor.d.ts CHANGED
@@ -52,6 +52,18 @@ export interface DoctorFixReport {
52
52
  /** 실제로 편집이 일어났는지(--yes 여부). */
53
53
  readonly applied: boolean;
54
54
  }
55
+ /**
56
+ * doctor 검사를 실행해 결과만 반환한다 — **stdout 에 아무것도 쓰지 않는다.**
57
+ * runDoctorCommand 는 CLI 진입점이라 결과를 stdout 으로 흘리지만, 프로그램에서
58
+ * (예: dev 헬스 엔드포인트가) 결과가 필요할 때는 이 함수를 쓴다.
59
+ *
60
+ * fatal(프로젝트 아님 · TS API 없음)도 write 없이 DoctorResult.fatal 로 담아
61
+ * 반환한다. --fix 는 다루지 않는다(읽기 전용).
62
+ */
63
+ export declare function computeDoctorResult(opts?: {
64
+ readonly cwd?: string;
65
+ readonly checks?: readonly DoctorRule[];
66
+ }): Promise<DoctorResult>;
55
67
  /**
56
68
  * `gaon doctor` 진입점 (M9-E · 확장 · M9-E-Fix 하드닝).
57
69
  *
package/dist/doctor.js CHANGED
@@ -92,6 +92,55 @@ const CHECKERS = {
92
92
  'page-filename': checkPageFilename,
93
93
  'auth-wiring': checkAuthWiring,
94
94
  };
95
+ /**
96
+ * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
97
+ * 계속 — 그 규칙만 error 리포트로 대체한다(부분 결과 확보).
98
+ */
99
+ async function runRules(root, rules) {
100
+ const reports = [];
101
+ for (const rule of rules) {
102
+ const fn = CHECKERS[rule];
103
+ try {
104
+ reports.push(await fn(root));
105
+ }
106
+ catch (err) {
107
+ const msg = err instanceof Error ? err.message : String(err);
108
+ reports.push({
109
+ rule,
110
+ issues: [
111
+ {
112
+ rule,
113
+ level: 'error',
114
+ message: `${rule} 검사 중 예외 발생: ${msg}\n` +
115
+ `→ 나머지 규칙만 임시로 실행하려면 '--check=<다른 규칙>' 를 쓰세요.`,
116
+ detail: { thrown: msg },
117
+ },
118
+ ],
119
+ });
120
+ }
121
+ }
122
+ return reports;
123
+ }
124
+ /**
125
+ * doctor 검사를 실행해 결과만 반환한다 — **stdout 에 아무것도 쓰지 않는다.**
126
+ * runDoctorCommand 는 CLI 진입점이라 결과를 stdout 으로 흘리지만, 프로그램에서
127
+ * (예: dev 헬스 엔드포인트가) 결과가 필요할 때는 이 함수를 쓴다.
128
+ *
129
+ * fatal(프로젝트 아님 · TS API 없음)도 write 없이 DoctorResult.fatal 로 담아
130
+ * 반환한다. --fix 는 다루지 않는다(읽기 전용).
131
+ */
132
+ export async function computeDoctorResult(opts = {}) {
133
+ const root = resolve(opts.cwd ?? process.cwd());
134
+ if (!detectProject(root)) {
135
+ return { passed: [], warnings: [], errors: [], fatal: fatalNoProject(root) };
136
+ }
137
+ if (!checkTypeScriptApi()) {
138
+ const installed = ts.version;
139
+ return { passed: [], warnings: [], errors: [], fatal: fatalTsApiMissing(installed) };
140
+ }
141
+ const rules = opts.checks && opts.checks.length ? opts.checks : ALL_RULES;
142
+ return makeResult(await runRules(root, rules));
143
+ }
95
144
  /**
96
145
  * `gaon doctor` 진입점 (M9-E · 확장 · M9-E-Fix 하드닝).
97
146
  *
@@ -139,30 +188,7 @@ export async function runDoctorCommand(opts = {}) {
139
188
  return result;
140
189
  }
141
190
  const rules = opts.checks && opts.checks.length ? opts.checks : ALL_RULES;
142
- const reports = [];
143
- for (const rule of rules) {
144
- const fn = CHECKERS[rule];
145
- try {
146
- reports.push(await fn(root));
147
- }
148
- catch (err) {
149
- // 규칙 하나가 크래시해도 나머지 규칙 실행은 계속한다. 사용자는
150
- // 크래시 대신 어느 규칙이 왜 실패했는지 안내받는다.
151
- const msg = err instanceof Error ? err.message : String(err);
152
- reports.push({
153
- rule,
154
- issues: [
155
- {
156
- rule,
157
- level: 'error',
158
- message: `${rule} 검사 중 예외 발생: ${msg}\n` +
159
- `→ 나머지 규칙만 임시로 실행하려면 '--check=<다른 규칙>' 를 쓰세요.`,
160
- detail: { thrown: msg },
161
- },
162
- ],
163
- });
164
- }
165
- }
191
+ const reports = await runRules(root, rules);
166
192
  const baseResult = makeResult(reports);
167
193
  // --fix 가 없으면 종전 동작 그대로.
168
194
  if (!opts.fix) {
package/dist/generate.js CHANGED
@@ -32,8 +32,11 @@ const TEMPLATES = [
32
32
  { tpl: 'session.controller.ts.tpl', out: (a) => `apps/${a}/controllers/session.ts` },
33
33
  { tpl: 'registration.controller.ts.tpl', out: (a) => `apps/${a}/controllers/registration.ts` },
34
34
  { tpl: 'dashboard.controller.ts.tpl', out: (a) => `apps/${a}/controllers/dashboard.ts` },
35
- { tpl: 'Login.vue.tpl', out: (a) => `apps/${a}/pages/auth/Login.vue` },
36
- { tpl: 'Signup.vue.tpl', out: (a) => `apps/${a}/pages/auth/Signup.vue` },
35
+ // 결정 32·46: Vue 페이지 경로 세그먼트는 PascalCase(Route 이름) 'Auth/'.
36
+ // examples/blog 정본과 page-filename doctor 규칙에 정합(소문자 'auth/'
37
+ // doctor 가 error 로 잡던 스캐폴드 표류였다 · W10 실측).
38
+ { tpl: 'Login.vue.tpl', out: (a) => `apps/${a}/pages/Auth/Login.vue` },
39
+ { tpl: 'Signup.vue.tpl', out: (a) => `apps/${a}/pages/Auth/Signup.vue` },
37
40
  { tpl: 'Dashboard.vue.tpl', out: (a) => `apps/${a}/pages/Dashboard.vue` },
38
41
  // 결정 59: 세션·인증 배선은 app.config.ts — 표준 부팅(gaon dev/serve = wireGaon)이
39
42
  // 소비한다. 과거의 수동 부팅 스캐폴드(app.ts·server.ts)는 두 번째 부팅 경로를
package/dist/index.d.ts CHANGED
@@ -10,12 +10,13 @@ export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthComma
10
10
  export { runGenerateCommand, planScaffold, parseGenerateArgs, type GenerateType, type GenerateOptions, type GenerateResult, } from "./commands/g.js";
11
11
  export { runHubCommand, type HubCommandOptions } from "./hub.js";
12
12
  export { runServeCommand, type ServeCommandOptions } from "./serve.js";
13
+ export { computeHealth, DEV_HEALTH_PATH, type DevHealthContext, type GaonHealth, } from "./dev/health.js";
13
14
  export { runWorkCommand, type WorkCommandOptions } from "./work.js";
14
15
  export { runJobsCommand, type JobsCommandOptions } from "./jobs.js";
15
16
  export { runDbSeedCommand, loadSeed, type DbSeedOptions, type DbSeedResult } from "./db.js";
16
17
  export { runDbCommand, type DbSubcommand, type DbCommandOptions, } from "./commands/db.js";
17
18
  export { runDbDiff, runDbMigrate, runDbReset, resolveDbTarget, type DbDiffOptions, type DbDiffResult, type DbMigrateOptions, type DbMigrateResult, type DbResetOptions, type DbResetResult, type ResolveDbOptions, type ResolvedDbTarget, } from "./db/index.js";
18
- export { runDoctorCommand, runDoctor, runDoctorFix, renderFixHuman, inspectControllerSource, checkResponseMixing, checkNPlusOne, checkDependencyDirection, checkConnections, checkMigrationDiff, renderHuman, renderJson, type DoctorResult, type DoctorResultWithFix, type DoctorCheck, type DoctorLevel, type DoctorRule, type RuleReport, type DoctorIssue, type LegacyDoctorResult, type ActionUsage, type ResponseKind, type DoctorCommandOptions, type FixOutcome, type DoctorFixReport, } from "./doctor.js";
19
+ export { runDoctorCommand, computeDoctorResult, runDoctor, runDoctorFix, renderFixHuman, inspectControllerSource, checkResponseMixing, checkNPlusOne, checkDependencyDirection, checkConnections, checkMigrationDiff, renderHuman, renderJson, type DoctorResult, type DoctorResultWithFix, type DoctorCheck, type DoctorLevel, type DoctorRule, type RuleReport, type DoctorIssue, type LegacyDoctorResult, type ActionUsage, type ResponseKind, type DoctorCommandOptions, type FixOutcome, type DoctorFixReport, } from "./doctor.js";
19
20
  export { FIXERS, FIXER_CAPABILITIES, fixDependencyDirection, fixDomainToSharedTypeOnly, type Fixer, type FixerCapability, type FixerPlan, } from "./doctor/fixers/index.js";
20
21
  export { loadDomain, type LoadedDomain } from "./domain.js";
21
22
  export interface RoadmapReport {
package/dist/index.js CHANGED
@@ -36,12 +36,15 @@ export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthComma
36
36
  export { runGenerateCommand, planScaffold, parseGenerateArgs, } from "./commands/g.js";
37
37
  export { runHubCommand } from "./hub.js";
38
38
  export { runServeCommand } from "./serve.js";
39
+ // dev 전용 라이브 헬스(결정 69). serve(--dev)가 등록하고, 브라우저 e2e 층이
40
+ // serve --dev 를 재현하기 위해 재사용한다.
41
+ export { computeHealth, DEV_HEALTH_PATH, } from "./dev/health.js";
39
42
  export { runWorkCommand } from "./work.js";
40
43
  export { runJobsCommand } from "./jobs.js";
41
44
  export { runDbSeedCommand, loadSeed } from "./db.js";
42
45
  export { runDbCommand, } from "./commands/db.js";
43
46
  export { runDbDiff, runDbMigrate, runDbReset, resolveDbTarget, } from "./db/index.js";
44
- export { runDoctorCommand, runDoctor, runDoctorFix, renderFixHuman, inspectControllerSource, checkResponseMixing, checkNPlusOne, checkDependencyDirection, checkConnections, checkMigrationDiff, renderHuman, renderJson, } from "./doctor.js";
47
+ export { runDoctorCommand, computeDoctorResult, runDoctor, runDoctorFix, renderFixHuman, inspectControllerSource, checkResponseMixing, checkNPlusOne, checkDependencyDirection, checkConnections, checkMigrationDiff, renderHuman, renderJson, } from "./doctor.js";
45
48
  export { FIXERS, FIXER_CAPABILITIES, fixDependencyDirection, fixDomainToSharedTypeOnly, } from "./doctor/fixers/index.js";
46
49
  export { loadDomain } from "./domain.js";
47
50
  /** `--json` 출력용 구조화 리포트. */
@@ -191,7 +194,9 @@ export function runCli(argv, opts = {}) {
191
194
  const hostIdx = argv.indexOf("--host");
192
195
  const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
193
196
  const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
194
- void runServeCommand({ json: argv.includes("--json"), port, host }).catch((err) => {
197
+ // --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식
198
+ // serve 에 넘긴다(결정 69 · dev-only by construction).
199
+ void runServeCommand({ json: argv.includes("--json"), port, host, dev: argv.includes("--dev") }).catch((err) => {
195
200
  const msg = err instanceof Error ? err.message : String(err);
196
201
  process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
197
202
  process.exitCode = 1;
package/dist/serve.d.ts CHANGED
@@ -5,6 +5,12 @@ export interface ServeCommandOptions {
5
5
  readonly port?: number;
6
6
  /** 리슨 호스트. 우선순위: 옵션 > config.web.host > '0.0.0.0'. */
7
7
  readonly host?: string;
8
+ /**
9
+ * dev 모드(gaon dev 자식). true 면 dev 전용 진단 라우트(/_gaon/health)를
10
+ * 등록한다. 운영 serve 는 이 플래그 없이 실행되어 진단 라우트가 노출되지
11
+ * 않는다(결정 69 · dev-only by construction).
12
+ */
13
+ readonly dev?: boolean;
8
14
  /** 프로세스 시그널(테스트 주입). 기본 process. */
9
15
  readonly signals?: {
10
16
  on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
package/dist/serve.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { loadDotEnv } from '@gaonjs/core';
15
15
  import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
16
16
  import { registerTsResolve } from './tsResolve.js';
17
+ import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
17
18
  function humanEvent(e) {
18
19
  switch (e.kind) {
19
20
  case 'starting': {
@@ -54,6 +55,14 @@ export async function runServeCommand(opts = {}) {
54
55
  const port = opts.port ??
55
56
  config.web?.port ??
56
57
  (process.env.PORT ? Number(process.env.PORT) : 3000);
58
+ // dev 전용 진단 라우트(결정 69). listen 전에 등록한다 — 운영 serve 는
59
+ // opts.dev 가 없어 등록되지 않으므로 /_gaon/health 는 production 에 없다.
60
+ // wired.app 은 여기서 FastifyInstance 로 해상되므로(cli 는 fastify 타입을
61
+ // 직접 의존하지 않는다) 라우트 등록은 serve 층에서 인라인으로 한다.
62
+ if (opts.dev) {
63
+ const healthCtx = { cwd, config, apps: wired.apps, nats: wired.nats, port, host };
64
+ wired.app.get(DEV_HEALTH_PATH, async () => computeHealth(healthCtx));
65
+ }
57
66
  await wired.app.listen({ host, port });
58
67
  const displayHost = host === '0.0.0.0' ? 'localhost' : host;
59
68
  emit({ kind: 'listening', host, port, url: `http://${displayHost}:${port}` });
@@ -5,7 +5,7 @@ import { User } from '../../../domain/models/User.js'
5
5
  export default controller({
6
6
  // GET /registration/new — 회원가입 폼
7
7
  async new() {
8
- return this.render('auth/Signup', { error: null as string | null, csrf: this.csrfToken() })
8
+ return this.render('Auth/Signup', { error: null as string | null, csrf: this.csrfToken() })
9
9
  },
10
10
  // POST /registration — 회원가입
11
11
  async create() {
@@ -5,7 +5,7 @@ import { User } from '../../../domain/models/User.js'
5
5
  export default controller({
6
6
  // GET /session/new — 로그인 폼
7
7
  async new() {
8
- return this.render('auth/Login', { error: null as string | null, csrf: this.csrfToken() })
8
+ return this.render('Auth/Login', { error: null as string | null, csrf: this.csrfToken() })
9
9
  },
10
10
  // POST /session — 로그인
11
11
  async create() {
@@ -16,7 +16,7 @@ export default controller({
16
16
  this.auth.login(user)
17
17
  return this.redirect('/dashboard')
18
18
  }
19
- return this.render('auth/Login', {
19
+ return this.render('Auth/Login', {
20
20
  error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
21
21
  csrf: this.csrfToken(),
22
22
  })
@@ -36,7 +36,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
36
36
  9. **실시간은 v1 포함**(§7): 웹서버 ↔ 허브는 TCP 지속 연결 · NATS 는
37
37
  broadcast 전용(errata E-2). 운영 프로세스는 serve·work·hub 3종.
38
38
  10. **인증·폼은 Inertia SPA**(§6 · SSR 아님). 로그인/회원가입은
39
- `this.render('auth/Login')` + `Inertia.post()` → 서버 redirect.
39
+ `this.render('Auth/Login')` + `Inertia.post()` → 서버 redirect.
40
40
  REST + `fetch()` 는 API 앱(JWT) 전용.
41
41
  11. **컴포저블·레이아웃**(errata E-5): 컴포저블은 컴포넌트와 대칭
42
42
  (`apps/<앱>/composables/` + `shared/composables/`, `use` 접두사).
@@ -456,6 +456,11 @@ gaon db seed # domain/seed.ts 실행
456
456
  잡으므로 이 함정 자체가 없다 — **손작성 마이그는 diff 로 표현 못 하는 것에만**
457
457
  쓴다(rename·백필·수동 DDL).
458
458
 
459
+ **단, 요구받은 마이그레이션을 생략하라는 뜻이 아니다** (결정 66). 지시나 리뷰가
460
+ 마이그레이션 파일을 명시적으로 요구하면 만들되, 위 방식(참조 대상을 같은
461
+ 파일에서 `ifNotExists()` 로 함께 보장)으로 함정을 피한다. 이 규칙은 마이그를
462
+ **쓸지 말지**가 아니라 **어떻게 쓸지**를 정한다.
463
+
459
464
  - **손작성 마이그는 `db/migrations/<파일명>.ts`** 에 두고 `up(db)`·`down(db)`
460
465
  (Kysely) 를 export 한다. **파일명이 곧 버전 키** — `0001_add_posts.ts`
461
466
  시퀀스든 `20260724_add_posts.ts` 타임스탬프든 사전순 정렬이 안정적이면 된다