@gaonjs/cli 0.14.0 → 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.
- package/dist/commands/dev.js +3 -1
- package/dist/dev/health.d.ts +72 -0
- package/dist/dev/health.js +154 -0
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +50 -24
- package/dist/generate.js +5 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +7 -2
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +9 -0
- package/dist/templates/auth/registration.controller.ts.tpl +1 -1
- package/dist/templates/auth/session.controller.ts.tpl +2 -2
- package/dist/templates/project/CLAUDE.md.tpl +1 -1
- package/dist/templates/project/agents/frontend.md.tpl +20 -0
- package/dist/templates/project/apps/web/composables/useGaonHealth.ts.tpl +79 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +93 -17
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +403 -20
- package/package.json +5 -5
package/dist/commands/dev.js
CHANGED
|
@@ -54,7 +54,9 @@ function resolveSelfCliEntry() {
|
|
|
54
54
|
*/
|
|
55
55
|
function spawnServe(args) {
|
|
56
56
|
const { node, args: nodeArgs } = resolveSelfCliEntry();
|
|
57
|
-
|
|
57
|
+
// --dev: dev 전용 진단 라우트(/_gaon/health)를 켠다(결정 69). gaon dev 의
|
|
58
|
+
// serve 자식만 이 플래그를 받으므로 운영 serve 에는 진단이 노출되지 않는다.
|
|
59
|
+
const serveArgs = ['serve', '--dev'];
|
|
58
60
|
if (args.json)
|
|
59
61
|
serveArgs.push('--json');
|
|
60
62
|
if (args.port != null)
|
|
@@ -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';
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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` 접두사).
|
|
@@ -151,6 +151,24 @@ Nuxt 식 자동 import 는 넣지 않는다. 모든 컴포넌트·컴포저블
|
|
|
151
151
|
import 한다. `gaon doctor` 의 **no-auto-import** 검사가 자동 import
|
|
152
152
|
설정을 잡는다.
|
|
153
153
|
|
|
154
|
+
### 7. 랜딩·라이브 헬스 (결정 69 · 70)
|
|
155
|
+
|
|
156
|
+
`gaon new` 첫 화면(`apps/web/pages/Home/Index.vue`)은 라이브 상태 랜딩이다 — 다크
|
|
157
|
+
헤더 레이아웃(`layouts/Default.vue`) + 실 상태 카드(WEB·DATABASE·HUB·DOCTOR) + 동적
|
|
158
|
+
다음 단계 + 실 `routes.ts` 코드 블록. 상태는 **하드코딩하지 않는다** — dev 전용
|
|
159
|
+
엔드포인트 `/_gaon/health` 를 컴포저블(`composables/useGaonHealth.ts`)로 읽어 바인딩한다.
|
|
160
|
+
이 엔드포인트는 `gaon dev` 에서만 등록되고(운영 빌드엔 없음), 404 면 컴포저블이
|
|
161
|
+
`available=false` 로 우아하게 degrade 한다.
|
|
162
|
+
|
|
163
|
+
- **스타일은 SFC `<style scoped>` + CSS 변수** — 스캐폴드에 Tailwind 를 넣지
|
|
164
|
+
않는다(의존성 경량 · 결정 69). 유틸 클래스 대신 scoped CSS 로 캡슐화.
|
|
165
|
+
- **auth 링크는 수동(결정 70)** — `gaon g auth` 는 auth 페이지·라우트만 신설하고
|
|
166
|
+
랜딩·레이아웃 nav 를 편집하지 않는다. 헤더에 로그인 링크를 두려면 `Default.vue`
|
|
167
|
+
의 nav 에 `<a href="/session/new">로그인</a>` 을 직접 추가한다(Rails 관례).
|
|
168
|
+
- **auth 페이지 경로 = `pages/Auth/`(PascalCase)** — `gaon g auth` 는 `Auth/Login.vue`
|
|
169
|
+
·`Auth/Signup.vue` 를 내고 컨트롤러는 `this.render('Auth/Login')` 로 부른다.
|
|
170
|
+
소문자 `auth/` 는 doctor page-filename 이 잡는다(결정 32·46).
|
|
171
|
+
|
|
154
172
|
## 정본 예시
|
|
155
173
|
|
|
156
174
|
```vue
|
|
@@ -203,4 +221,6 @@ async function runSearch(q: string) {
|
|
|
203
221
|
| 결정 25 (E-5) | 컴포저블·레이아웃 관례 · 프론트 로직 배치 3규칙 · 자동 import 금지 |
|
|
204
222
|
| 결정 37 | bigint PK 컨트롤러 `String()` 정규화 |
|
|
205
223
|
| 결정 46 | doctor page-filename(페이지 PascalCase)·model/column 검사 3종 |
|
|
224
|
+
| 결정 69 | 랜딩 정본(라이브 헬스 카드 · 다크 헤더 레이아웃 · Tailwind 미편입 · scoped CSS) |
|
|
225
|
+
| 결정 70 | auth 통합 = 수동(`gaon g auth` 는 랜딩·nav 를 안 건드림 · Rails 관례) |
|
|
206
226
|
| E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// useGaonHealth — 앱 전용 컴포저블(errata E-5 §2.1).
|
|
2
|
+
// gaon dev 가 띄운 서버의 dev 전용 진단 엔드포인트(/_gaon/health)를 읽어
|
|
3
|
+
// 랜딩(Home/Index.vue)의 라이브 상태 카드에 바인딩한다.
|
|
4
|
+
//
|
|
5
|
+
// dev-only: 이 엔드포인트는 gaon dev(= gaon serve --dev)에서만 등록된다.
|
|
6
|
+
// 운영 빌드에는 없다 — 404 면 available=false 로 우아하게 degrade하고(카드가
|
|
7
|
+
// "개발 서버에서만" 로 표시), 에러를 던지지 않는다.
|
|
8
|
+
import { ref, onMounted } from 'vue'
|
|
9
|
+
|
|
10
|
+
// 서버 응답(GaonHealth) 의 앱 쪽 미러. 서버 타입을 import 하지 않고(앱은 프레임웍
|
|
11
|
+
// 내부 타입에 의존하지 않는다) 랜딩이 쓰는 필드만 선언한다.
|
|
12
|
+
export interface HealthWeb {
|
|
13
|
+
host: string
|
|
14
|
+
port: number
|
|
15
|
+
node: string
|
|
16
|
+
gaonjs: string
|
|
17
|
+
apps: { name: string; prefix: string }[]
|
|
18
|
+
}
|
|
19
|
+
export interface HealthDatabase {
|
|
20
|
+
configured: boolean
|
|
21
|
+
connected?: boolean
|
|
22
|
+
adapter?: string
|
|
23
|
+
tables?: number
|
|
24
|
+
tableNames?: string[]
|
|
25
|
+
migrations?: number
|
|
26
|
+
error?: string
|
|
27
|
+
}
|
|
28
|
+
export interface HealthHub {
|
|
29
|
+
configured: boolean
|
|
30
|
+
connected?: boolean
|
|
31
|
+
streams?: { stream: string; waiting: number | null }[]
|
|
32
|
+
error?: string
|
|
33
|
+
}
|
|
34
|
+
export interface HealthDoctor {
|
|
35
|
+
checks: number
|
|
36
|
+
passed: number
|
|
37
|
+
warnings: number
|
|
38
|
+
errors: number
|
|
39
|
+
level: 'pass' | 'warn' | 'error'
|
|
40
|
+
fatal?: string
|
|
41
|
+
}
|
|
42
|
+
export interface GaonHealth {
|
|
43
|
+
ok: boolean
|
|
44
|
+
env: string
|
|
45
|
+
web: HealthWeb
|
|
46
|
+
database: HealthDatabase
|
|
47
|
+
hub: HealthHub
|
|
48
|
+
doctor: HealthDoctor
|
|
49
|
+
routes: { path: string; source: string } | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useGaonHealth() {
|
|
53
|
+
const health = ref<GaonHealth | null>(null)
|
|
54
|
+
const loading = ref(true)
|
|
55
|
+
// 엔드포인트가 있는가(= dev 서버인가). 운영 빌드에선 false 로 남는다.
|
|
56
|
+
const available = ref(false)
|
|
57
|
+
|
|
58
|
+
async function load(): Promise<void> {
|
|
59
|
+
loading.value = true
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch('/_gaon/health', { headers: { Accept: 'application/json' } })
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
available.value = false
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
health.value = (await res.json()) as GaonHealth
|
|
67
|
+
available.value = true
|
|
68
|
+
} catch {
|
|
69
|
+
// 네트워크·파싱 실패 = 진단 불가. 랜딩은 정적 안내로 degrade.
|
|
70
|
+
available.value = false
|
|
71
|
+
} finally {
|
|
72
|
+
loading.value = false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
onMounted(load)
|
|
77
|
+
|
|
78
|
+
return { health, loading, available, reload: load }
|
|
79
|
+
}
|
|
@@ -5,39 +5,115 @@
|
|
|
5
5
|
//
|
|
6
6
|
// 레이아웃은 shared 에 두지 않는다(E-5 §2.3) — 앱마다 레이아웃이 다른 것이
|
|
7
7
|
// 정상이고, 공용 조각(로고·푸터 등) 만 shared/components 로 뽑는다.
|
|
8
|
+
//
|
|
9
|
+
// 다크 헤더 + 라이트 본문(결정 69). 버전은 스캐폴드 시점 gaonjs 버전이 박힌다.
|
|
10
|
+
//
|
|
11
|
+
// 결정 70(auth = 수동): gaon g auth 는 이 nav 를 건드리지 않는다. 로그인 링크를
|
|
12
|
+
// 헤더에 두려면 아래 nav 에 <a href="/session/new">로그인</a> 을 직접 추가한다.
|
|
13
|
+
|
|
14
|
+
// package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
|
|
15
|
+
const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
|
|
8
16
|
</script>
|
|
9
17
|
|
|
10
18
|
<template>
|
|
11
|
-
<div class="layout">
|
|
12
|
-
<header class="
|
|
13
|
-
<
|
|
19
|
+
<div class="gaon-layout">
|
|
20
|
+
<header class="gaon-header">
|
|
21
|
+
<a class="gaon-brand" href="/">
|
|
22
|
+
<span class="gaon-brand-mark">가온</span>
|
|
23
|
+
<span class="gaon-brand-word">GAONJS</span>
|
|
24
|
+
<span class="gaon-brand-ver">v{{ version }}</span>
|
|
25
|
+
</a>
|
|
26
|
+
<nav class="gaon-nav">
|
|
27
|
+
<a href="https://gaonjs.dev" target="_blank" rel="noreferrer">문서</a>
|
|
28
|
+
<a href="https://github.com/gaonjs" target="_blank" rel="noreferrer">GitHub</a>
|
|
29
|
+
</nav>
|
|
14
30
|
</header>
|
|
15
|
-
|
|
16
|
-
<
|
|
17
|
-
<
|
|
31
|
+
|
|
32
|
+
<main class="gaon-main">
|
|
33
|
+
<slot />
|
|
34
|
+
</main>
|
|
35
|
+
|
|
36
|
+
<footer class="gaon-footer">
|
|
37
|
+
<small>{{PROJECT_NAME}} · Powered by
|
|
38
|
+
<a href="https://gaonjs.dev" target="_blank" rel="noreferrer">Gaon</a></small>
|
|
18
39
|
</footer>
|
|
19
40
|
</div>
|
|
20
41
|
</template>
|
|
21
42
|
|
|
22
43
|
<style scoped>
|
|
23
|
-
.layout {
|
|
44
|
+
.gaon-layout {
|
|
24
45
|
min-height: 100vh;
|
|
25
46
|
display: flex;
|
|
26
47
|
flex-direction: column;
|
|
48
|
+
background: #ffffff;
|
|
49
|
+
color: #1b1f24;
|
|
50
|
+
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* 다크 헤더 — 본문 테마와 무관하게 브랜드 바는 어둡게. */
|
|
54
|
+
.gaon-header {
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
justify-content: space-between;
|
|
58
|
+
gap: 1rem;
|
|
59
|
+
padding: 0.85rem 1.5rem;
|
|
60
|
+
background: #0d1117;
|
|
61
|
+
color: #e6edf3;
|
|
62
|
+
border-bottom: 1px solid #21262d;
|
|
63
|
+
}
|
|
64
|
+
.gaon-brand {
|
|
65
|
+
display: inline-flex;
|
|
66
|
+
align-items: baseline;
|
|
67
|
+
gap: 0.55rem;
|
|
68
|
+
text-decoration: none;
|
|
69
|
+
color: inherit;
|
|
27
70
|
}
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
padding:
|
|
31
|
-
|
|
32
|
-
|
|
71
|
+
.gaon-brand-mark {
|
|
72
|
+
display: inline-block;
|
|
73
|
+
padding: 0.15rem 0.45rem;
|
|
74
|
+
border-radius: 6px;
|
|
75
|
+
background: linear-gradient(135deg, #4f8cff, #7c5cff);
|
|
76
|
+
color: #fff;
|
|
77
|
+
font-weight: 700;
|
|
78
|
+
font-size: 0.8rem;
|
|
79
|
+
letter-spacing: 0.02em;
|
|
33
80
|
}
|
|
34
|
-
.
|
|
35
|
-
|
|
81
|
+
.gaon-brand-word {
|
|
82
|
+
font-weight: 700;
|
|
83
|
+
letter-spacing: 0.08em;
|
|
84
|
+
font-size: 0.95rem;
|
|
36
85
|
}
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
|
|
86
|
+
.gaon-brand-ver {
|
|
87
|
+
font-size: 0.72rem;
|
|
88
|
+
color: #8b949e;
|
|
89
|
+
font-variant-numeric: tabular-nums;
|
|
90
|
+
}
|
|
91
|
+
.gaon-nav {
|
|
92
|
+
display: flex;
|
|
93
|
+
gap: 1.1rem;
|
|
94
|
+
font-size: 0.85rem;
|
|
95
|
+
}
|
|
96
|
+
.gaon-nav a {
|
|
97
|
+
color: #c9d1d9;
|
|
98
|
+
text-decoration: none;
|
|
99
|
+
}
|
|
100
|
+
.gaon-nav a:hover {
|
|
101
|
+
color: #fff;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.gaon-main {
|
|
105
|
+
flex: 1;
|
|
106
|
+
width: 100%;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.gaon-footer {
|
|
110
|
+
padding: 1.25rem 1.5rem;
|
|
40
111
|
text-align: center;
|
|
41
112
|
color: #6a737d;
|
|
113
|
+
border-top: 1px solid #eaecef;
|
|
114
|
+
}
|
|
115
|
+
.gaon-footer a {
|
|
116
|
+
color: #4f8cff;
|
|
117
|
+
text-decoration: none;
|
|
42
118
|
}
|
|
43
119
|
</style>
|
|
@@ -1,36 +1,419 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
2
3
|
import { pageProps } from 'gaonjs/vue'
|
|
4
|
+
import { useGaonHealth, type HealthDoctor } from '../../composables/useGaonHealth.js'
|
|
3
5
|
|
|
4
6
|
// home#index 의 render props — Serialized<> 로 넘어온다(§6.2).
|
|
5
7
|
// 라우트 키는 .gaon/routes.d.ts 가 유효한 값을 알려준다.
|
|
6
8
|
const props = pageProps<'web:home#index'>()
|
|
9
|
+
|
|
10
|
+
// 라이브 상태(dev 전용 /_gaon/health). 운영 빌드에선 available=false 로 degrade.
|
|
11
|
+
const { health, loading, available } = useGaonHealth()
|
|
12
|
+
|
|
13
|
+
type CardStatus = 'ok' | 'warn' | 'error' | 'idle'
|
|
14
|
+
interface Card {
|
|
15
|
+
readonly key: string
|
|
16
|
+
readonly title: string
|
|
17
|
+
readonly status: CardStatus
|
|
18
|
+
readonly headline: string
|
|
19
|
+
readonly lines: readonly string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function doctorNote(d: HealthDoctor | undefined): string {
|
|
23
|
+
if (!d) return '—'
|
|
24
|
+
if (d.fatal) return `검사 불가 (${d.fatal})`
|
|
25
|
+
return `${d.passed} 통과 · ${d.warnings} 경고 · ${d.errors} 오류`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const cards = computed<Card[]>(() => {
|
|
29
|
+
const h = health.value
|
|
30
|
+
const web = h?.web
|
|
31
|
+
const db = h?.database
|
|
32
|
+
const hub = h?.hub
|
|
33
|
+
const doc = h?.doctor
|
|
34
|
+
|
|
35
|
+
const webCard: Card = {
|
|
36
|
+
key: 'web',
|
|
37
|
+
title: 'WEB',
|
|
38
|
+
status: available.value ? 'ok' : 'idle',
|
|
39
|
+
headline: web ? `:${web.port}` : '대기',
|
|
40
|
+
lines: web
|
|
41
|
+
? [`Node ${web.node}`, `gaonjs v${web.gaonjs}`, `앱 ${web.apps.map((a) => a.name).join(', ')}`]
|
|
42
|
+
: ['개발 서버에서만'],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dbCard: Card = {
|
|
46
|
+
key: 'database',
|
|
47
|
+
title: 'DATABASE',
|
|
48
|
+
status: !db || !db.configured ? 'idle' : db.connected ? 'ok' : 'error',
|
|
49
|
+
headline: !db || !db.configured ? '미설정' : db.connected ? '연결됨' : '미연결',
|
|
50
|
+
lines:
|
|
51
|
+
db && db.configured
|
|
52
|
+
? db.connected
|
|
53
|
+
? [`${db.adapter ?? 'postgres'}`, `테이블 ${db.tables ?? 0}개`, `마이그레이션 ${db.migrations ?? 0}건`]
|
|
54
|
+
: [db.error ?? '연결 실패', '.env DATABASE_URL 확인']
|
|
55
|
+
: ['.env 에 DATABASE_URL', 'gaon.config.ts 에서 켜기'],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const hubCard: Card = {
|
|
59
|
+
key: 'hub',
|
|
60
|
+
title: 'HUB',
|
|
61
|
+
status: !hub || !hub.configured ? 'idle' : hub.connected ? 'ok' : 'error',
|
|
62
|
+
headline: !hub || !hub.configured ? '미설정' : hub.connected ? '연결됨' : '미연결',
|
|
63
|
+
lines:
|
|
64
|
+
hub && hub.configured
|
|
65
|
+
? hub.connected
|
|
66
|
+
? (hub.streams ?? []).map((s) => `${s.stream}: ${s.waiting === null ? '대기 없음' : `${s.waiting} 대기`}`)
|
|
67
|
+
: [hub.error ?? 'NATS 미연결']
|
|
68
|
+
: ['.env 에 NATS_URL', '실시간·비동기 백본'],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const doctorCard: Card = {
|
|
72
|
+
key: 'doctor',
|
|
73
|
+
title: 'DOCTOR',
|
|
74
|
+
status: !doc ? 'idle' : doc.level === 'pass' ? 'ok' : doc.level === 'warn' ? 'warn' : 'error',
|
|
75
|
+
headline: !doc
|
|
76
|
+
? '대기'
|
|
77
|
+
: doc.fatal
|
|
78
|
+
? '검사 불가'
|
|
79
|
+
: doc.errors > 0
|
|
80
|
+
? `오류 ${doc.errors}`
|
|
81
|
+
: doc.warnings > 0
|
|
82
|
+
? `경고 ${doc.warnings}`
|
|
83
|
+
: '통과',
|
|
84
|
+
lines: [doctorNote(doc), 'gaon check 로 재실행'],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return [webCard, dbCard, hubCard, doctorCard]
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// 다음 단계 — 라이브 상태에서 완료 여부를 파생한다.
|
|
91
|
+
interface Step {
|
|
92
|
+
readonly label: string
|
|
93
|
+
readonly cmd: string
|
|
94
|
+
readonly done: boolean
|
|
95
|
+
readonly note: string
|
|
96
|
+
}
|
|
97
|
+
const steps = computed<Step[]>(() => {
|
|
98
|
+
const h = health.value
|
|
99
|
+
const db = h?.database
|
|
100
|
+
const configured = !!(db && db.configured)
|
|
101
|
+
const connected = !!(db && db.configured && db.connected)
|
|
102
|
+
const tables = configured ? db!.tables ?? 0 : 0
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
label: '개발 스택 부팅',
|
|
106
|
+
cmd: 'gaon dev',
|
|
107
|
+
done: available.value,
|
|
108
|
+
note: 'Docker · .gaon 타입 브리지 · 서버 · watch',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
label: '데이터베이스 연결',
|
|
112
|
+
cmd: configured ? 'gaon db migrate' : '.env: DATABASE_URL',
|
|
113
|
+
done: connected,
|
|
114
|
+
note: configured ? (connected ? '연결됨' : '설정됨 · 미연결') : '미설정',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
label: '첫 모델 만들기',
|
|
118
|
+
cmd: 'gaon g model Post',
|
|
119
|
+
done: tables > 0,
|
|
120
|
+
note: tables > 0 ? `테이블 ${tables}개` : '스키마 + 모델 (E-4)',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
label: '인증 배터리',
|
|
124
|
+
cmd: 'gaon g auth',
|
|
125
|
+
done: false,
|
|
126
|
+
note: '가입 · 로그인 · 세션 (수동 통합 · 결정 70)',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
label: '검사 통과',
|
|
130
|
+
cmd: 'gaon check',
|
|
131
|
+
done: h?.doctor.level === 'pass',
|
|
132
|
+
note: doctorNote(h?.doctor),
|
|
133
|
+
},
|
|
134
|
+
]
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// routes.ts 코드 블록 — 라이브 원문을 줄 단위로 나누고 주석만 가볍게 색을 준다.
|
|
138
|
+
// v-html 없이(텍스트 보간만) 안전하게 그린다. 의존성 없는 최소 하이라이트.
|
|
139
|
+
interface CodeLine {
|
|
140
|
+
readonly code: string
|
|
141
|
+
readonly comment: string
|
|
142
|
+
}
|
|
143
|
+
function splitComment(line: string): CodeLine {
|
|
144
|
+
const m = line.match(/\s\/\/.*$/)
|
|
145
|
+
if (m && m.index !== undefined) return { code: line.slice(0, m.index), comment: line.slice(m.index) }
|
|
146
|
+
if (line.trimStart().startsWith('//')) return { code: '', comment: line }
|
|
147
|
+
return { code: line, comment: '' }
|
|
148
|
+
}
|
|
149
|
+
const routesPath = computed(() => health.value?.routes?.path ?? 'apps/web/routes.ts')
|
|
150
|
+
const routeLines = computed<CodeLine[]>(() => {
|
|
151
|
+
const src = health.value?.routes?.source
|
|
152
|
+
if (!src) return []
|
|
153
|
+
return src.replace(/\n+$/, '').split('\n').map(splitComment)
|
|
154
|
+
})
|
|
7
155
|
</script>
|
|
8
156
|
|
|
9
157
|
<template>
|
|
10
|
-
<
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
158
|
+
<div class="landing">
|
|
159
|
+
<section class="hero">
|
|
160
|
+
<p class="eyebrow">{{ props.title }}</p>
|
|
161
|
+
<h1>가온에 올라탔습니다.</h1>
|
|
162
|
+
<p class="lede">
|
|
163
|
+
이 화면은 <code>apps/web/pages/Home/Index.vue</code> 입니다.
|
|
164
|
+
<code>routes.ts</code> 의 <code>r.get('/', 'home#index')</code> 가 여기로 연결했습니다.
|
|
165
|
+
</p>
|
|
166
|
+
<p class="lede sub">
|
|
167
|
+
아래 카드는 지금 이 서버의 <strong>실제 상태</strong>입니다 —
|
|
168
|
+
하드코딩이 아니라 <code>/_gaon/health</code> 를 읽어 그립니다.
|
|
169
|
+
<a :href="props.docs" target="_blank" rel="noreferrer">{{ props.docs }}</a>
|
|
170
|
+
</p>
|
|
171
|
+
</section>
|
|
172
|
+
|
|
173
|
+
<section class="cards" aria-label="프레임웍 상태">
|
|
174
|
+
<article v-for="c in cards" :key="c.key" class="card" :class="`is-${c.status}`">
|
|
175
|
+
<header class="card-top">
|
|
176
|
+
<span class="dot" :class="`dot-${c.status}`" aria-hidden="true"></span>
|
|
177
|
+
<span class="card-title">{{ c.title }}</span>
|
|
178
|
+
</header>
|
|
179
|
+
<p class="card-headline">{{ c.headline }}</p>
|
|
180
|
+
<ul class="card-lines">
|
|
181
|
+
<li v-for="(ln, i) in c.lines" :key="i">{{ ln }}</li>
|
|
182
|
+
</ul>
|
|
183
|
+
</article>
|
|
184
|
+
</section>
|
|
185
|
+
|
|
186
|
+
<p v-if="loading" class="hint">상태 확인 중…</p>
|
|
187
|
+
<p v-else-if="!available" class="hint">
|
|
188
|
+
라이브 상태는 <code>gaon dev</code> 개발 서버에서만 보입니다(운영 빌드엔 진단 엔드포인트가 없습니다).
|
|
15
189
|
</p>
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
190
|
+
|
|
191
|
+
<section class="next">
|
|
192
|
+
<h2>다음 단계</h2>
|
|
193
|
+
<ul class="steps">
|
|
194
|
+
<li v-for="s in steps" :key="s.label" :class="{ done: s.done }">
|
|
195
|
+
<span class="check" aria-hidden="true">{{ s.done ? '✓' : '○' }}</span>
|
|
196
|
+
<div class="step-body">
|
|
197
|
+
<div class="step-head">
|
|
198
|
+
<span class="step-label">{{ s.label }}</span>
|
|
199
|
+
<code class="step-cmd">{{ s.cmd }}</code>
|
|
200
|
+
</div>
|
|
201
|
+
<span class="step-note">{{ s.note }}</span>
|
|
202
|
+
</div>
|
|
203
|
+
</li>
|
|
204
|
+
</ul>
|
|
205
|
+
</section>
|
|
206
|
+
|
|
207
|
+
<section v-if="routeLines.length" class="code">
|
|
208
|
+
<div class="code-head">{{ routesPath }}</div>
|
|
209
|
+
<pre class="code-body"><code><span v-for="(ln, i) in routeLines" :key="i" class="code-line"><span class="c-code">{{ ln.code }}</span><span class="c-comment">{{ ln.comment }}</span>{{ '\n' }}</span></code></pre>
|
|
210
|
+
</section>
|
|
211
|
+
</div>
|
|
26
212
|
</template>
|
|
27
213
|
|
|
28
214
|
<style scoped>
|
|
29
|
-
.
|
|
30
|
-
max-width:
|
|
31
|
-
margin:
|
|
32
|
-
padding:
|
|
33
|
-
|
|
215
|
+
.landing {
|
|
216
|
+
max-width: 880px;
|
|
217
|
+
margin: 0 auto;
|
|
218
|
+
padding: 3rem 1.25rem 4rem;
|
|
219
|
+
color: #1b1f24;
|
|
34
220
|
line-height: 1.6;
|
|
35
221
|
}
|
|
222
|
+
|
|
223
|
+
.hero {
|
|
224
|
+
margin-bottom: 2.5rem;
|
|
225
|
+
}
|
|
226
|
+
.eyebrow {
|
|
227
|
+
margin: 0 0 0.4rem;
|
|
228
|
+
font-size: 0.8rem;
|
|
229
|
+
font-weight: 600;
|
|
230
|
+
letter-spacing: 0.08em;
|
|
231
|
+
text-transform: uppercase;
|
|
232
|
+
color: #7c5cff;
|
|
233
|
+
}
|
|
234
|
+
.hero h1 {
|
|
235
|
+
margin: 0 0 0.9rem;
|
|
236
|
+
font-size: clamp(2rem, 5vw, 2.9rem);
|
|
237
|
+
line-height: 1.15;
|
|
238
|
+
letter-spacing: -0.02em;
|
|
239
|
+
}
|
|
240
|
+
.lede {
|
|
241
|
+
margin: 0.35rem 0;
|
|
242
|
+
color: #3a424c;
|
|
243
|
+
font-size: 1.02rem;
|
|
244
|
+
}
|
|
245
|
+
.lede.sub {
|
|
246
|
+
color: #57606a;
|
|
247
|
+
font-size: 0.95rem;
|
|
248
|
+
}
|
|
249
|
+
.lede a {
|
|
250
|
+
color: #4f8cff;
|
|
251
|
+
}
|
|
252
|
+
code {
|
|
253
|
+
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
|
|
254
|
+
font-size: 0.86em;
|
|
255
|
+
background: #f2f4f7;
|
|
256
|
+
padding: 0.1rem 0.35rem;
|
|
257
|
+
border-radius: 5px;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.cards {
|
|
261
|
+
display: grid;
|
|
262
|
+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
263
|
+
gap: 1rem;
|
|
264
|
+
margin-bottom: 1rem;
|
|
265
|
+
}
|
|
266
|
+
.card {
|
|
267
|
+
border: 1px solid #e4e7eb;
|
|
268
|
+
border-radius: 12px;
|
|
269
|
+
padding: 1.1rem 1.15rem;
|
|
270
|
+
background: #fff;
|
|
271
|
+
box-shadow: 0 1px 2px rgba(16, 22, 26, 0.04);
|
|
272
|
+
}
|
|
273
|
+
.card-top {
|
|
274
|
+
display: flex;
|
|
275
|
+
align-items: center;
|
|
276
|
+
gap: 0.5rem;
|
|
277
|
+
margin-bottom: 0.6rem;
|
|
278
|
+
}
|
|
279
|
+
.card-title {
|
|
280
|
+
font-size: 0.74rem;
|
|
281
|
+
font-weight: 700;
|
|
282
|
+
letter-spacing: 0.08em;
|
|
283
|
+
color: #6a737d;
|
|
284
|
+
}
|
|
285
|
+
.dot {
|
|
286
|
+
width: 9px;
|
|
287
|
+
height: 9px;
|
|
288
|
+
border-radius: 50%;
|
|
289
|
+
background: #c2c8cf;
|
|
290
|
+
}
|
|
291
|
+
.dot-ok {
|
|
292
|
+
background: #2da44e;
|
|
293
|
+
box-shadow: 0 0 0 3px rgba(45, 164, 78, 0.15);
|
|
294
|
+
}
|
|
295
|
+
.dot-warn {
|
|
296
|
+
background: #d4a72c;
|
|
297
|
+
box-shadow: 0 0 0 3px rgba(212, 167, 44, 0.15);
|
|
298
|
+
}
|
|
299
|
+
.dot-error {
|
|
300
|
+
background: #cf222e;
|
|
301
|
+
box-shadow: 0 0 0 3px rgba(207, 34, 46, 0.15);
|
|
302
|
+
}
|
|
303
|
+
.card-headline {
|
|
304
|
+
margin: 0 0 0.5rem;
|
|
305
|
+
font-size: 1.35rem;
|
|
306
|
+
font-weight: 700;
|
|
307
|
+
letter-spacing: -0.01em;
|
|
308
|
+
font-variant-numeric: tabular-nums;
|
|
309
|
+
}
|
|
310
|
+
.card-lines {
|
|
311
|
+
margin: 0;
|
|
312
|
+
padding: 0;
|
|
313
|
+
list-style: none;
|
|
314
|
+
font-size: 0.82rem;
|
|
315
|
+
color: #57606a;
|
|
316
|
+
}
|
|
317
|
+
.card-lines li {
|
|
318
|
+
padding: 0.08rem 0;
|
|
319
|
+
overflow-wrap: anywhere;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.hint {
|
|
323
|
+
margin: 0.25rem 0 1.5rem;
|
|
324
|
+
font-size: 0.85rem;
|
|
325
|
+
color: #6a737d;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.next {
|
|
329
|
+
margin: 2.5rem 0;
|
|
330
|
+
}
|
|
331
|
+
.next h2 {
|
|
332
|
+
font-size: 1.15rem;
|
|
333
|
+
margin: 0 0 1rem;
|
|
334
|
+
}
|
|
335
|
+
.steps {
|
|
336
|
+
list-style: none;
|
|
337
|
+
margin: 0;
|
|
338
|
+
padding: 0;
|
|
339
|
+
display: flex;
|
|
340
|
+
flex-direction: column;
|
|
341
|
+
gap: 0.35rem;
|
|
342
|
+
}
|
|
343
|
+
.steps li {
|
|
344
|
+
display: flex;
|
|
345
|
+
gap: 0.75rem;
|
|
346
|
+
align-items: flex-start;
|
|
347
|
+
padding: 0.65rem 0.85rem;
|
|
348
|
+
border: 1px solid #eaecef;
|
|
349
|
+
border-radius: 10px;
|
|
350
|
+
background: #fbfcfd;
|
|
351
|
+
}
|
|
352
|
+
.steps li.done {
|
|
353
|
+
border-color: #cbe7d3;
|
|
354
|
+
background: #f3faf5;
|
|
355
|
+
}
|
|
356
|
+
.check {
|
|
357
|
+
color: #b0b7bf;
|
|
358
|
+
font-weight: 700;
|
|
359
|
+
line-height: 1.5;
|
|
360
|
+
}
|
|
361
|
+
.steps li.done .check {
|
|
362
|
+
color: #2da44e;
|
|
363
|
+
}
|
|
364
|
+
.step-body {
|
|
365
|
+
flex: 1;
|
|
366
|
+
min-width: 0;
|
|
367
|
+
}
|
|
368
|
+
.step-head {
|
|
369
|
+
display: flex;
|
|
370
|
+
align-items: center;
|
|
371
|
+
gap: 0.6rem;
|
|
372
|
+
flex-wrap: wrap;
|
|
373
|
+
}
|
|
374
|
+
.step-label {
|
|
375
|
+
font-weight: 600;
|
|
376
|
+
}
|
|
377
|
+
.step-cmd {
|
|
378
|
+
background: #0d1117;
|
|
379
|
+
color: #e6edf3;
|
|
380
|
+
}
|
|
381
|
+
.step-note {
|
|
382
|
+
display: block;
|
|
383
|
+
font-size: 0.8rem;
|
|
384
|
+
color: #6a737d;
|
|
385
|
+
margin-top: 0.15rem;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.code {
|
|
389
|
+
border: 1px solid #e4e7eb;
|
|
390
|
+
border-radius: 12px;
|
|
391
|
+
overflow: hidden;
|
|
392
|
+
}
|
|
393
|
+
.code-head {
|
|
394
|
+
padding: 0.55rem 1rem;
|
|
395
|
+
background: #f6f8fa;
|
|
396
|
+
border-bottom: 1px solid #e4e7eb;
|
|
397
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
398
|
+
font-size: 0.78rem;
|
|
399
|
+
color: #57606a;
|
|
400
|
+
}
|
|
401
|
+
.code-body {
|
|
402
|
+
margin: 0;
|
|
403
|
+
padding: 1rem 1.15rem;
|
|
404
|
+
overflow-x: auto;
|
|
405
|
+
background: #0d1117;
|
|
406
|
+
color: #c9d1d9;
|
|
407
|
+
font-size: 0.82rem;
|
|
408
|
+
line-height: 1.65;
|
|
409
|
+
}
|
|
410
|
+
.code-body code {
|
|
411
|
+
background: none;
|
|
412
|
+
padding: 0;
|
|
413
|
+
font-size: inherit;
|
|
414
|
+
color: inherit;
|
|
415
|
+
}
|
|
416
|
+
.c-comment {
|
|
417
|
+
color: #8b949e;
|
|
418
|
+
}
|
|
36
419
|
</style>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/async": "0.
|
|
31
|
-
"@gaonjs/
|
|
32
|
-
"@gaonjs/
|
|
33
|
-
"@gaonjs/data": "0.8.4",
|
|
30
|
+
"@gaonjs/async": "0.4.0",
|
|
31
|
+
"@gaonjs/web": "0.6.1",
|
|
32
|
+
"@gaonjs/config": "0.4.1",
|
|
34
33
|
"@gaonjs/core": "0.2.0",
|
|
34
|
+
"@gaonjs/data": "0.8.4",
|
|
35
35
|
"@gaonjs/mail": "0.1.1"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|