@gaonjs/cli 0.4.0 → 0.10.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/check.d.ts +50 -0
- package/dist/commands/check.js +286 -0
- package/dist/commands/console.d.ts +46 -0
- package/dist/commands/console.js +129 -0
- package/dist/commands/db.d.ts +3 -1
- package/dist/commands/db.js +8 -2
- package/dist/commands/g.d.ts +1 -1
- package/dist/commands/g.js +27 -3
- package/dist/commands/mcp.d.ts +15 -0
- package/dist/commands/mcp.js +78 -0
- package/dist/commands/new.d.ts +45 -0
- package/dist/commands/new.js +274 -0
- package/dist/commands/test.d.ts +11 -0
- package/dist/commands/test.js +119 -0
- package/dist/db/diff.js +5 -0
- package/dist/db/journal.d.ts +34 -0
- package/dist/db/journal.js +71 -0
- package/dist/db/migrate.d.ts +6 -1
- package/dist/db/migrate.js +120 -102
- package/dist/db/replay.d.ts +49 -0
- package/dist/db/replay.js +148 -0
- package/dist/db/status.d.ts +12 -0
- package/dist/db/status.js +61 -0
- package/dist/dev/index.d.ts +2 -0
- package/dist/dev/index.js +2 -0
- package/dist/dev/vite.d.ts +67 -0
- package/dist/dev/vite.js +126 -0
- package/dist/dev.d.ts +18 -0
- package/dist/dev.js +15 -0
- package/dist/doctor/agents-doc-index.d.ts +4 -0
- package/dist/doctor/agents-doc-index.js +80 -0
- package/dist/doctor/fixers/dependency-direction.d.ts +9 -0
- package/dist/doctor/fixers/dependency-direction.js +98 -0
- package/dist/doctor/fixers/index.d.ts +15 -0
- package/dist/doctor/fixers/index.js +66 -0
- package/dist/doctor/fixers/schema-filename.d.ts +14 -0
- package/dist/doctor/fixers/schema-filename.js +104 -0
- package/dist/doctor/fixers/types.d.ts +59 -0
- package/dist/doctor/fixers/types.js +15 -0
- package/dist/doctor/no-auto-import.d.ts +10 -0
- package/dist/doctor/no-auto-import.js +158 -0
- package/dist/doctor/schema-filename.d.ts +6 -0
- package/dist/doctor/schema-filename.js +81 -0
- package/dist/doctor/shared-composable-purity.d.ts +8 -0
- package/dist/doctor/shared-composable-purity.js +164 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +6 -5
- package/dist/doctor.d.ts +51 -0
- package/dist/doctor.js +191 -7
- package/dist/generate.js +2 -2
- package/dist/hub.d.ts +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +154 -16
- package/dist/mcp/index.d.ts +7 -0
- package/dist/mcp/index.js +7 -0
- package/dist/mcp/server.d.ts +50 -0
- package/dist/mcp/server.js +102 -0
- package/dist/mcp/tools.d.ts +109 -0
- package/dist/mcp/tools.js +485 -0
- package/dist/scaffold/app.d.ts +5 -0
- package/dist/scaffold/app.js +172 -0
- package/dist/scaffold/controller.js +2 -2
- package/dist/scaffold/index.d.ts +2 -1
- package/dist/scaffold/index.js +2 -1
- package/dist/scaffold/job.d.ts +5 -0
- package/dist/scaffold/job.js +35 -0
- package/dist/scaffold/model.js +8 -8
- package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
- package/dist/templates/auth/registration.controller.ts.tpl +1 -1
- package/dist/templates/auth/session.controller.ts.tpl +1 -1
- package/dist/templates/auth/user.model.ts.tpl +1 -1
- package/dist/templates/index.d.ts +23 -0
- package/dist/templates/index.js +66 -0
- package/dist/templates/index.ts +85 -0
- package/dist/templates/project/.env.example.tpl +18 -0
- package/dist/templates/project/.gitignore.tpl +24 -0
- package/dist/templates/project/.npmrc.tpl +4 -0
- package/dist/templates/project/AGENTS.md.tpl +210 -0
- package/dist/templates/project/CLAUDE.md.tpl +119 -0
- package/dist/templates/project/agents/async.md.tpl +218 -0
- package/dist/templates/project/agents/data.md.tpl +532 -0
- package/dist/templates/project/agents/frontend.md.tpl +201 -0
- package/dist/templates/project/agents/realtime.md.tpl +157 -0
- package/dist/templates/project/agents/security.md.tpl +92 -0
- package/dist/templates/project/agents/testing.md.tpl +101 -0
- package/dist/templates/project/agents/web.md.tpl +177 -0
- package/dist/templates/project/apps/web/channels/.gitkeep.tpl +1 -0
- package/dist/templates/project/apps/web/components/.gitkeep.tpl +1 -0
- package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +25 -0
- package/dist/templates/project/apps/web/controllers/home.ts.tpl +19 -0
- package/dist/templates/project/apps/web/index.html.tpl +18 -0
- package/dist/templates/project/apps/web/layouts/Default.vue.tpl +43 -0
- package/dist/templates/project/apps/web/main.ts.tpl +24 -0
- package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +36 -0
- package/dist/templates/project/apps/web/routes.ts.tpl +8 -0
- package/dist/templates/project/docker-compose.yaml.tpl +73 -0
- package/dist/templates/project/domain/events/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/jobs/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/listeners/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/mails/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/models/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/schema/.gitkeep.tpl +1 -0
- package/dist/templates/project/domain/services/.gitkeep.tpl +1 -0
- package/dist/templates/project/gaon.config.ts.tpl +27 -0
- package/dist/templates/project/package.json.tpl +30 -0
- package/dist/templates/project/pnpm-workspace.yaml.tpl +11 -0
- package/dist/templates/project/shared/components/.gitkeep.tpl +1 -0
- package/dist/templates/project/shared/composables/useDebounce.ts.tpl +21 -0
- package/dist/templates/project/tsconfig.json.tpl +25 -0
- package/dist/templates/project/vite.config.ts.tpl +23 -0
- package/dist/tsResolve.js +1 -1
- package/dist/work.d.ts +2 -2
- package/dist/work.js +3 -1
- package/package.json +13 -11
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.d.ts +0 -12
- package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +0 -7
- package/dist/__fixtures__/db-minimal/gaon.config.d.ts +0 -2
- package/dist/__fixtures__/db-minimal/gaon.config.js +0 -11
- package/dist/check.d.ts +0 -29
- package/dist/check.js +0 -92
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gaonjs/cli · MCP 도구 4종 — v0.15 §12 질문 8 원문 명세.
|
|
3
|
+
*
|
|
4
|
+
* 정본 §12 질문 8 원문:
|
|
5
|
+
* "내장 MCP 서버 — 종결 (v0.11). 권장안 채택: 최소 도구 4종
|
|
6
|
+
* (라우트 목록, 스키마 조회, 마이그레이션 실행, 테스트 실행)으로 시작."
|
|
7
|
+
*
|
|
8
|
+
* 정본 §7.5.3 (에러 = 수리 안내서 · 내장 MCP 서버) 원문:
|
|
9
|
+
* "gaon mcp 로 프레임웍이 자신을 AI 도구로 노출한다: 라우트 목록,
|
|
10
|
+
* 스키마 조회, 마이그레이션 실행, 테스트 실행을 구조화된 도구로 제공.
|
|
11
|
+
* 에이전트가 grep 으로 프로젝트를 더듬는 대신 프레임웍에게 직접 묻는다."
|
|
12
|
+
*
|
|
13
|
+
* 도구 명명(kebab_case → 함수명 snake_case):
|
|
14
|
+
* list_routes — 라우트 목록 (§13.5 M10 · 질문 8 ①)
|
|
15
|
+
* get_schema — 스키마 조회 (§13.5 M10 · 질문 8 ②)
|
|
16
|
+
* run_migration — 마이그레이션 실행 (§13.5 M10 · 질문 8 ③)
|
|
17
|
+
* run_tests — 테스트 실행 (§13.5 M10 · 질문 8 ④)
|
|
18
|
+
*
|
|
19
|
+
* 실 인프라 §9 — 각 도구는 실제 CLI 로직을 재사용해 실 실행한다. 목업 X.
|
|
20
|
+
* list_routes → @gaonjs/config resolveApps + @gaonjs/web routes 파싱
|
|
21
|
+
* get_schema → @gaonjs/data scanSchemaDir + columns() 조회
|
|
22
|
+
* run_migration→ ../db/migrate.ts (dryRun 지원)
|
|
23
|
+
* run_tests → ../commands/test.ts (실 vitest spawn)
|
|
24
|
+
*
|
|
25
|
+
* 반환은 항상 사람용 텍스트 + 구조화 JSON(둘 다). MCP 스펙상
|
|
26
|
+
* content[] 에는 텍스트만 담고, structuredContent 에 원본 JSON 을 넣는다 —
|
|
27
|
+
* 클라이언트가 렌더링·파싱 어느 쪽이든 쓸 수 있게.
|
|
28
|
+
*/
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
31
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
32
|
+
import { pathToFileURL } from 'node:url';
|
|
33
|
+
import { registerTsResolve } from '../tsResolve.js';
|
|
34
|
+
import { runDbMigrate } from '../db/migrate.js';
|
|
35
|
+
import { scanSchemaDir, columns } from '@gaonjs/data';
|
|
36
|
+
// ── 유틸 ────────────────────────────────────────────────────
|
|
37
|
+
/** 문자열 옵션 안전 추출. */
|
|
38
|
+
function stringOpt(args, key) {
|
|
39
|
+
const v = args[key];
|
|
40
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** 불리언 옵션 안전 추출. */
|
|
43
|
+
function boolOpt(args, key) {
|
|
44
|
+
return args[key] === true;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 에러 → ToolResult. 사용자가 볼 텍스트에는 "→ 수리 안내" 를 포함한다
|
|
48
|
+
* (§7.5.3 원칙 · 에러가 곧 실행 가능한 수리 명령).
|
|
49
|
+
*/
|
|
50
|
+
function errorResult(message, extra) {
|
|
51
|
+
return {
|
|
52
|
+
text: `[gaon mcp] ${message}`,
|
|
53
|
+
data: { ok: false, error: message, ...extra },
|
|
54
|
+
isError: true,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// ── 도구 1: list_routes ──────────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* `list_routes` — 프로젝트의 모든 앱과 라우트를 나열한다.
|
|
60
|
+
*
|
|
61
|
+
* 관례(§3.3): apps/<name>/routes.ts 의 default export 가 routes(...) 결과
|
|
62
|
+
* (RouteDef · entries[]). @gaonjs/config resolveApps 가 발견·로드한다.
|
|
63
|
+
*
|
|
64
|
+
* 인자:
|
|
65
|
+
* { app?: string } — 지정 시 그 앱만. 없으면 전 앱.
|
|
66
|
+
*
|
|
67
|
+
* 반환:
|
|
68
|
+
* data.apps: [{ name, routeCount, routes: [{ method, path, controller, action }] }]
|
|
69
|
+
*/
|
|
70
|
+
export async function listRoutesTool(args, cwd) {
|
|
71
|
+
registerTsResolve();
|
|
72
|
+
const appFilter = stringOpt(args, 'app');
|
|
73
|
+
// 정본 §3.3 관례를 그대로 재사용. resolveApps 는 @gaonjs/config 에 있지만
|
|
74
|
+
// CLI 는 config 를 workspace 의존으로 이미 갖고 있다.
|
|
75
|
+
const { resolveApps } = await import('@gaonjs/config');
|
|
76
|
+
const apps = await resolveApps(cwd);
|
|
77
|
+
const filtered = appFilter ? apps.filter((a) => a.name === appFilter) : apps;
|
|
78
|
+
if (appFilter && filtered.length === 0) {
|
|
79
|
+
const known = apps.map((a) => a.name).sort();
|
|
80
|
+
return errorResult(`앱 '${appFilter}' 을(를) 찾을 수 없습니다.\n` +
|
|
81
|
+
` 발견된 앱: ${known.length ? known.join(', ') : '(없음 · apps/<name>/routes.ts 를 만드세요)'}`);
|
|
82
|
+
}
|
|
83
|
+
const summary = [];
|
|
84
|
+
for (const app of filtered) {
|
|
85
|
+
const def = app.routes;
|
|
86
|
+
const entries = Array.isArray(def?.entries) ? def.entries : [];
|
|
87
|
+
summary.push({
|
|
88
|
+
name: app.name,
|
|
89
|
+
dir: app.dir,
|
|
90
|
+
routeCount: entries.length,
|
|
91
|
+
routes: entries.map((e) => ({
|
|
92
|
+
method: e.method,
|
|
93
|
+
path: e.path,
|
|
94
|
+
controller: e.controller,
|
|
95
|
+
action: e.action,
|
|
96
|
+
})),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const totalRoutes = summary.reduce((a, s) => a + s.routeCount, 0);
|
|
100
|
+
const lines = [];
|
|
101
|
+
if (summary.length === 0) {
|
|
102
|
+
lines.push('(발견된 앱이 없습니다. apps/<name>/routes.ts 관례를 확인하세요.)');
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
for (const app of summary) {
|
|
106
|
+
lines.push(`■ ${app.name} (${app.routeCount}개)`);
|
|
107
|
+
for (const r of app.routes) {
|
|
108
|
+
lines.push(` ${r.method.toUpperCase().padEnd(6)} ${r.path.padEnd(30)} → ${r.controller}#${r.action}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
lines.push('');
|
|
112
|
+
lines.push(`총 ${summary.length}개 앱 · ${totalRoutes}개 라우트`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
text: lines.join('\n'),
|
|
116
|
+
data: { ok: true, apps: summary, totalApps: summary.length, totalRoutes },
|
|
117
|
+
isError: false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function columnToInfo(name, meta) {
|
|
121
|
+
const info = {
|
|
122
|
+
name,
|
|
123
|
+
sqlType: meta.sqlType,
|
|
124
|
+
nullable: meta.nullable,
|
|
125
|
+
primaryKey: meta.primaryKey,
|
|
126
|
+
hasDefault: meta.hasDefault,
|
|
127
|
+
hidden: meta.hidden,
|
|
128
|
+
references: meta.references,
|
|
129
|
+
maxLen: meta.maxLen,
|
|
130
|
+
precision: meta.precision,
|
|
131
|
+
scale: meta.scale,
|
|
132
|
+
enumValues: meta.enumValues,
|
|
133
|
+
unique: meta.unique,
|
|
134
|
+
index: meta.index,
|
|
135
|
+
checkExpr: meta.checkExpr,
|
|
136
|
+
};
|
|
137
|
+
return info;
|
|
138
|
+
}
|
|
139
|
+
function isTableDef(v) {
|
|
140
|
+
return (typeof v === 'object' &&
|
|
141
|
+
v !== null &&
|
|
142
|
+
'name' in v &&
|
|
143
|
+
'defs' in v &&
|
|
144
|
+
'db' in v &&
|
|
145
|
+
typeof v.name === 'string');
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* `get_schema` — domain/schema/ 의 테이블 정의를 조회한다.
|
|
149
|
+
*
|
|
150
|
+
* 관례(§4.1): domain/schema/*.ts 의 export 로 table(...) 결과가 놓인다.
|
|
151
|
+
* @gaonjs/data scanSchemaDir 로 모듈을 로드하고, TableDef 만 추려 컬럼 메타를
|
|
152
|
+
* 함께 낸다. 커넥션 키(§4.5) · 복합 제약(§4.2) 도 포함.
|
|
153
|
+
*
|
|
154
|
+
* 인자:
|
|
155
|
+
* { table?: string, db?: string }
|
|
156
|
+
* table — 지정 시 그 테이블만
|
|
157
|
+
* db — 지정 시 그 커넥션 것만 (기본: 전 커넥션)
|
|
158
|
+
*
|
|
159
|
+
* 반환:
|
|
160
|
+
* data.tables: [{ name, db, columns: [...], constraints: { unique, index, check } }]
|
|
161
|
+
*/
|
|
162
|
+
export async function getSchemaTool(args, cwd) {
|
|
163
|
+
registerTsResolve();
|
|
164
|
+
const tableFilter = stringOpt(args, 'table');
|
|
165
|
+
const dbFilter = stringOpt(args, 'db');
|
|
166
|
+
const schemaDir = join(resolvePath(cwd), 'domain', 'schema');
|
|
167
|
+
if (!existsSync(schemaDir)) {
|
|
168
|
+
return errorResult(`domain/schema/ 디렉터리가 없습니다.\n` +
|
|
169
|
+
`→ ${schemaDir} 에 스키마 파일(예: post.ts)을 만들고 \`export const posts = table('posts', { ... })\` 로 선언하세요.`);
|
|
170
|
+
}
|
|
171
|
+
const outDir = join(resolvePath(cwd), '.gaon');
|
|
172
|
+
const modules = await scanSchemaDir(schemaDir, outDir);
|
|
173
|
+
const all = [];
|
|
174
|
+
for (const mod of modules) {
|
|
175
|
+
for (const [, value] of Object.entries(mod.ns)) {
|
|
176
|
+
if (isTableDef(value))
|
|
177
|
+
all.push(value);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const filtered = all.filter((t) => {
|
|
181
|
+
if (tableFilter && t.name !== tableFilter)
|
|
182
|
+
return false;
|
|
183
|
+
if (dbFilter && t.db !== dbFilter)
|
|
184
|
+
return false;
|
|
185
|
+
return true;
|
|
186
|
+
});
|
|
187
|
+
if (tableFilter && filtered.length === 0) {
|
|
188
|
+
const known = all.map((t) => t.name).sort();
|
|
189
|
+
return errorResult(`테이블 '${tableFilter}' 을(를) 찾을 수 없습니다.\n` +
|
|
190
|
+
` 발견된 테이블: ${known.length ? known.join(', ') : '(없음)'}`);
|
|
191
|
+
}
|
|
192
|
+
const tables = filtered
|
|
193
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
194
|
+
.map((t) => ({
|
|
195
|
+
name: t.name,
|
|
196
|
+
db: t.db,
|
|
197
|
+
columns: columns(t).map(([n, m]) => columnToInfo(n, m)),
|
|
198
|
+
constraints: {
|
|
199
|
+
unique: t.constraints.unique,
|
|
200
|
+
index: t.constraints.index,
|
|
201
|
+
check: t.constraints.check,
|
|
202
|
+
},
|
|
203
|
+
}));
|
|
204
|
+
const lines = [];
|
|
205
|
+
if (tables.length === 0) {
|
|
206
|
+
lines.push('(발견된 테이블이 없습니다. domain/schema/*.ts 에 table(...) 을 export 하세요.)');
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
for (const t of tables) {
|
|
210
|
+
lines.push(`■ ${t.name} (db='${t.db}')`);
|
|
211
|
+
for (const c of t.columns) {
|
|
212
|
+
const flags = [];
|
|
213
|
+
if (c.primaryKey)
|
|
214
|
+
flags.push('PK');
|
|
215
|
+
if (c.nullable)
|
|
216
|
+
flags.push('null');
|
|
217
|
+
if (c.hasDefault)
|
|
218
|
+
flags.push('default');
|
|
219
|
+
if (c.hidden)
|
|
220
|
+
flags.push('hidden');
|
|
221
|
+
if (c.unique)
|
|
222
|
+
flags.push('unique');
|
|
223
|
+
if (c.references)
|
|
224
|
+
flags.push(`→ ${c.references}`);
|
|
225
|
+
const flagStr = flags.length > 0 ? ` [${flags.join(', ')}]` : '';
|
|
226
|
+
lines.push(` ${c.name.padEnd(24)} ${c.sqlType}${flagStr}`);
|
|
227
|
+
}
|
|
228
|
+
const uniq = t.constraints.unique ?? [];
|
|
229
|
+
const idx = t.constraints.index ?? [];
|
|
230
|
+
const chk = t.constraints.check ?? [];
|
|
231
|
+
if (uniq.length > 0)
|
|
232
|
+
lines.push(` unique: ${uniq.map((u) => `[${u.join(', ')}]`).join(', ')}`);
|
|
233
|
+
if (idx.length > 0)
|
|
234
|
+
lines.push(` index: ${idx.map((u) => `[${u.join(', ')}]`).join(', ')}`);
|
|
235
|
+
if (chk.length > 0)
|
|
236
|
+
lines.push(` check: ${chk.map(([n]) => n).join(', ')}`);
|
|
237
|
+
}
|
|
238
|
+
lines.push('');
|
|
239
|
+
lines.push(`총 ${tables.length}개 테이블`);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
text: lines.join('\n'),
|
|
243
|
+
data: { ok: true, tables, total: tables.length },
|
|
244
|
+
isError: false,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
// ── 도구 3: run_migration ────────────────────────────────────
|
|
248
|
+
/**
|
|
249
|
+
* `run_migration` — 스키마 diff 를 계산해 DB 에 적용한다.
|
|
250
|
+
*
|
|
251
|
+
* 실제 로직은 `../db/migrate.ts` (M9-D · gaon db migrate) 를 그대로 재사용.
|
|
252
|
+
* 실 DB 필요(§9) — 목업 없이 진짜 커넥션에 실행한다.
|
|
253
|
+
*
|
|
254
|
+
* 인자:
|
|
255
|
+
* { db?: string, dryRun?: boolean }
|
|
256
|
+
* db — 커넥션 키(§4.5). 기본 'main'.
|
|
257
|
+
* dryRun — true 면 SQL 만 계산·출력, DB 미변경.
|
|
258
|
+
*
|
|
259
|
+
* 반환: runDbMigrate 의 결과 그대로(text + json).
|
|
260
|
+
*/
|
|
261
|
+
export async function runMigrationTool(args, cwd) {
|
|
262
|
+
const dbKey = stringOpt(args, 'db') ?? 'main';
|
|
263
|
+
const dryRun = boolOpt(args, 'dryRun');
|
|
264
|
+
try {
|
|
265
|
+
const r = await runDbMigrate({ cwd, dbKey, json: true, dryRun });
|
|
266
|
+
return {
|
|
267
|
+
text: r.text,
|
|
268
|
+
data: r.json,
|
|
269
|
+
isError: r.exitCode !== 0,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
274
|
+
return errorResult(msg);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// ── 도구 4: run_tests ────────────────────────────────────────
|
|
278
|
+
/**
|
|
279
|
+
* `run_tests` — 프로젝트 테스트를 실 실행한다(§9 · vitest spawn).
|
|
280
|
+
*
|
|
281
|
+
* `gaon test` 와 같은 경로를 쓰되, MCP 컨텍스트에서는 stdout 을 파이프로
|
|
282
|
+
* 캡처해서 결과를 도구 응답에 담아 돌려준다(자식이 종료할 때까지 대기).
|
|
283
|
+
*
|
|
284
|
+
* 인자:
|
|
285
|
+
* { scope?: 'unit'|'integration'|'all', filter?: string }
|
|
286
|
+
* scope — 기본 'all'
|
|
287
|
+
* filter — vitest 위치 인자(파일 패턴 substring)
|
|
288
|
+
*
|
|
289
|
+
* 반환:
|
|
290
|
+
* data: { exitCode, scope, filter, output }
|
|
291
|
+
* text: vitest 자체 출력(사람 UI 그대로)
|
|
292
|
+
*/
|
|
293
|
+
export async function runTestsTool(args, cwd) {
|
|
294
|
+
const scope = (stringOpt(args, 'scope') ?? 'all');
|
|
295
|
+
const filter = stringOpt(args, 'filter');
|
|
296
|
+
if (!['unit', 'integration', 'all'].includes(scope)) {
|
|
297
|
+
return errorResult(`scope 는 'unit' | 'integration' | 'all' 중 하나여야 합니다. 입력값: '${scope}'`);
|
|
298
|
+
}
|
|
299
|
+
// scopeArgs 는 test.ts 와 동일 규칙을 재구성한다 — 순환 import 를 피하려고
|
|
300
|
+
// 여기서 직접 계산한다(관례는 test.ts scopeArgs 와 정합).
|
|
301
|
+
const scopeExtras = scope === 'unit'
|
|
302
|
+
? ['--exclude', '**/*.integration.test.ts']
|
|
303
|
+
: scope === 'integration'
|
|
304
|
+
? ['integration.test']
|
|
305
|
+
: [];
|
|
306
|
+
const passthrough = [...scopeExtras, ...(filter ? [filter] : [])];
|
|
307
|
+
// 실행 경로: 로컬 vitest 바이너리 우선. 없으면 에러 안내.
|
|
308
|
+
const vitestBin = join(resolvePath(cwd), 'node_modules', '.bin', 'vitest');
|
|
309
|
+
if (!existsSync(vitestBin)) {
|
|
310
|
+
return errorResult(`vitest 가 설치돼 있지 않습니다: ${vitestBin}\n` +
|
|
311
|
+
`→ 프로젝트에서 \`pnpm add -D vitest\` 후 다시 시도하세요.`);
|
|
312
|
+
}
|
|
313
|
+
const child = spawn(vitestBin, ['run', ...passthrough], {
|
|
314
|
+
cwd,
|
|
315
|
+
env: process.env,
|
|
316
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
317
|
+
});
|
|
318
|
+
let stdout = '';
|
|
319
|
+
let stderr = '';
|
|
320
|
+
child.stdout.on('data', (d) => {
|
|
321
|
+
stdout += d.toString('utf8');
|
|
322
|
+
});
|
|
323
|
+
child.stderr.on('data', (d) => {
|
|
324
|
+
stderr += d.toString('utf8');
|
|
325
|
+
});
|
|
326
|
+
const exitCode = await new Promise((resolveExit) => {
|
|
327
|
+
child.on('close', (code) => resolveExit(code ?? 1));
|
|
328
|
+
});
|
|
329
|
+
const output = stdout + (stderr ? `\n[stderr]\n${stderr}` : '');
|
|
330
|
+
return {
|
|
331
|
+
text: output || `(vitest 출력 없음 · exit ${exitCode})`,
|
|
332
|
+
data: {
|
|
333
|
+
ok: exitCode === 0,
|
|
334
|
+
exitCode,
|
|
335
|
+
scope,
|
|
336
|
+
filter: filter ?? null,
|
|
337
|
+
output,
|
|
338
|
+
},
|
|
339
|
+
isError: exitCode !== 0,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
// ── 도구 5: read_agent_doc ──────────────────────────────────
|
|
343
|
+
/**
|
|
344
|
+
* `read_agent_doc` — 관례 문서 2층 구조(결정 40)의 카테고리 문서를 조회한다.
|
|
345
|
+
*
|
|
346
|
+
* 루트 AGENTS.md(코어) 색인은 "해당 영역 작업 전에 agents/<카테고리>.md 를
|
|
347
|
+
* 읽어라" 고 지시한다. 파일시스템 접근이 제한된 MCP 클라이언트도 이 지시를
|
|
348
|
+
* 이행할 수 있도록 문서 조회를 구조화 도구로 노출한다 — grep 대체 원칙
|
|
349
|
+
* (§7.5.3) 의 문서판.
|
|
350
|
+
*
|
|
351
|
+
* 인자:
|
|
352
|
+
* { category?: string } — data·web·frontend·async·realtime·testing·security.
|
|
353
|
+
* 생략 시 사용 가능한 카테고리 목록을 반환.
|
|
354
|
+
*/
|
|
355
|
+
export async function readAgentDocTool(args, cwd) {
|
|
356
|
+
const agentsDir = join(cwd, 'agents');
|
|
357
|
+
if (!existsSync(agentsDir)) {
|
|
358
|
+
return errorResult(`agents/ 폴더가 없습니다: ${agentsDir}\n` +
|
|
359
|
+
` → 프로젝트 루트에서 실행 중인지 확인하세요. gaon new 스캐폴드는 AGENTS.md 와 agents/*.md 를 함께 생성합니다.`);
|
|
360
|
+
}
|
|
361
|
+
const available = readdirSync(agentsDir)
|
|
362
|
+
.filter((f) => f.endsWith('.md'))
|
|
363
|
+
.map((f) => f.slice(0, -'.md'.length))
|
|
364
|
+
.sort();
|
|
365
|
+
const category = stringOpt(args, 'category');
|
|
366
|
+
if (!category) {
|
|
367
|
+
return {
|
|
368
|
+
text: `사용 가능한 카테고리: ${available.join(', ')}\n → read_agent_doc { category: '<이름>' } 으로 원문을 조회하세요.`,
|
|
369
|
+
data: { ok: true, categories: available },
|
|
370
|
+
isError: false,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const file = join(agentsDir, `${category}.md`);
|
|
374
|
+
if (!existsSync(file)) {
|
|
375
|
+
return errorResult(`카테고리 문서가 없습니다: agents/${category}.md\n` +
|
|
376
|
+
` → 사용 가능한 카테고리: ${available.join(', ')}`, { categories: available });
|
|
377
|
+
}
|
|
378
|
+
const body = readFileSync(file, 'utf8');
|
|
379
|
+
return {
|
|
380
|
+
text: body,
|
|
381
|
+
data: { ok: true, category, path: `agents/${category}.md`, contents: body },
|
|
382
|
+
isError: false,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* 정본 §12 질문 8 확정 명세 4종을 카탈로그로 제공한다. 순서 = 원문 순서
|
|
387
|
+
* (라우트 · 스키마 · 마이그레이션 · 테스트).
|
|
388
|
+
*/
|
|
389
|
+
export const TOOLS = [
|
|
390
|
+
{
|
|
391
|
+
name: 'list_routes',
|
|
392
|
+
description: '프로젝트의 모든 앱과 라우트를 나열한다. 인자로 app 을 주면 그 앱만 필터. ' +
|
|
393
|
+
'apps/<name>/routes.ts 관례를 재사용한다(§3.3).',
|
|
394
|
+
inputSchema: {
|
|
395
|
+
type: 'object',
|
|
396
|
+
properties: {
|
|
397
|
+
app: { type: 'string', description: '앱 이름(예: web, api). 생략 시 전 앱.' },
|
|
398
|
+
},
|
|
399
|
+
additionalProperties: false,
|
|
400
|
+
},
|
|
401
|
+
run: listRoutesTool,
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
name: 'get_schema',
|
|
405
|
+
description: 'domain/schema/ 의 테이블 정의(§4.1)를 조회한다. 컬럼·타입·제약·커넥션 키(§4.5) 를 함께 낸다. ' +
|
|
406
|
+
'table 또는 db 인자로 필터 가능.',
|
|
407
|
+
inputSchema: {
|
|
408
|
+
type: 'object',
|
|
409
|
+
properties: {
|
|
410
|
+
table: { type: 'string', description: '테이블 이름(예: posts). 생략 시 전 테이블.' },
|
|
411
|
+
db: { type: 'string', description: '커넥션 키(예: main, analytics). 생략 시 전 커넥션.' },
|
|
412
|
+
},
|
|
413
|
+
additionalProperties: false,
|
|
414
|
+
},
|
|
415
|
+
run: getSchemaTool,
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
name: 'run_migration',
|
|
419
|
+
description: '스키마 desired 상태와 실 DB 를 비교해 마이그레이션을 실 적용한다(§9 실 인프라). ' +
|
|
420
|
+
'dryRun=true 면 SQL 만 계산·출력하고 DB 는 미변경. db 는 커넥션 키(기본 main).',
|
|
421
|
+
inputSchema: {
|
|
422
|
+
type: 'object',
|
|
423
|
+
properties: {
|
|
424
|
+
db: { type: 'string', description: '커넥션 키(§4.5). 기본 main.' },
|
|
425
|
+
dryRun: { type: 'boolean', description: 'true 면 SQL 만 계산·출력.' },
|
|
426
|
+
},
|
|
427
|
+
additionalProperties: false,
|
|
428
|
+
},
|
|
429
|
+
run: runMigrationTool,
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: 'run_tests',
|
|
433
|
+
description: '프로젝트 테스트를 실 vitest 로 실행한다(§9). scope 로 unit/integration/all 중 선택, ' +
|
|
434
|
+
'filter 로 파일 패턴(substring) 을 좁힐 수 있다.',
|
|
435
|
+
inputSchema: {
|
|
436
|
+
type: 'object',
|
|
437
|
+
properties: {
|
|
438
|
+
scope: {
|
|
439
|
+
type: 'string',
|
|
440
|
+
enum: ['unit', 'integration', 'all'],
|
|
441
|
+
description: 'unit=*.test.ts, integration=*.integration.test.ts, all=둘 다. 기본 all.',
|
|
442
|
+
},
|
|
443
|
+
filter: { type: 'string', description: 'vitest 위치 인자(파일명 substring).' },
|
|
444
|
+
},
|
|
445
|
+
additionalProperties: false,
|
|
446
|
+
},
|
|
447
|
+
run: runTestsTool,
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
name: 'read_agent_doc',
|
|
451
|
+
description: '관례 문서 2층 구조(결정 40)의 카테고리 문서(agents/*.md)를 조회한다. ' +
|
|
452
|
+
'category 생략 시 사용 가능한 카테고리 목록. 해당 영역 작업 전에 반드시 읽는다 — 루트 AGENTS.md 색인 지시.',
|
|
453
|
+
inputSchema: {
|
|
454
|
+
type: 'object',
|
|
455
|
+
properties: {
|
|
456
|
+
category: {
|
|
457
|
+
type: 'string',
|
|
458
|
+
description: 'data·web·frontend·async·realtime·testing·security 중 하나. 생략 시 목록 반환.',
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
additionalProperties: false,
|
|
462
|
+
},
|
|
463
|
+
run: readAgentDocTool,
|
|
464
|
+
},
|
|
465
|
+
];
|
|
466
|
+
/**
|
|
467
|
+
* 이름으로 도구를 찾는다. 없으면 undefined.
|
|
468
|
+
*/
|
|
469
|
+
export function findTool(name) {
|
|
470
|
+
return TOOLS.find((t) => t.name === name);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* 앱 관례 검사(진단용) — apps/ 존재만 확인. 도구 실행 전에 조기 종료를 하지 않고,
|
|
474
|
+
* 각 도구가 자체 관례 검사를 하도록 한다(에러 = 수리 안내 · §7.5.3).
|
|
475
|
+
* 이 함수는 외부에서 문서 생성용으로만 쓴다(현재 unused 지만 진단 훅 자리).
|
|
476
|
+
*/
|
|
477
|
+
export function projectSummary(cwd) {
|
|
478
|
+
const root = resolvePath(cwd);
|
|
479
|
+
return {
|
|
480
|
+
hasApps: existsSync(join(root, 'apps')) && readdirSync(join(root, 'apps'), { withFileTypes: true }).some((e) => e.isDirectory()),
|
|
481
|
+
hasSchema: existsSync(join(root, 'domain', 'schema')),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
// 순환 참조 회피 — pathToFileURL 이 사용됨을 확실히(트리쉐이킹 안전).
|
|
485
|
+
void pathToFileURL;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ScaffoldFile } from './controller.js';
|
|
2
|
+
/** 이름 유효성 — apps/<name>/ 이 안전하고 URL 프리픽스로도 무리 없는 부분집합. */
|
|
3
|
+
export declare function validateAppName(name: string): void;
|
|
4
|
+
/** 앱 스캐폴드 파일 계획. 순수 함수 — 실제 쓰기는 writeScaffold 가 담당. */
|
|
5
|
+
export declare function appScaffoldFiles(name: string): ScaffoldFile[];
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// @gaonjs/cli · scaffold · app (M9-fix · v0.16 §3 · §13.5 M9)
|
|
2
|
+
//
|
|
3
|
+
// `gaon g app <name>` — 다중 앱 스캐폴드. v0.16 §3 정본:
|
|
4
|
+
// "필요해지면 gaon g app admin 한 줄로 앱이 늘어난다 — 구조 개편이 없다."
|
|
5
|
+
// 새 앱 이름은 URL 프리픽스가 되며(§3.3, web 은 '/', 그 외는 '/<name>'),
|
|
6
|
+
// 개발 모드에서는 `<이름>.localhost` 로도 접근된다(§3.3 · v0.11 확정).
|
|
7
|
+
//
|
|
8
|
+
// 만드는 파일:
|
|
9
|
+
// apps/<name>/routes.ts (라우트 최소 예제 · health JSON)
|
|
10
|
+
// apps/<name>/app.config.ts (관례 이탈용 · 주석뿐인 빈 설정)
|
|
11
|
+
// apps/<name>/controllers/home.ts (페이지 + JSON 액션 예제 · E-3)
|
|
12
|
+
// apps/<name>/pages/Home/Index.vue (Inertia SPA · pageProps<>() 브리지)
|
|
13
|
+
// apps/<name>/layouts/Default.vue (E-5 §2.3 · 파일 존재 = 등록)
|
|
14
|
+
// apps/<name>/components/.gitkeep (관례 유지)
|
|
15
|
+
// apps/<name>/composables/.gitkeep (E-5 §2.2 · 앱 종속 컴포저블 자리)
|
|
16
|
+
// apps/<name>/channels/.gitkeep (M6 · 실시간 채널 자리)
|
|
17
|
+
//
|
|
18
|
+
// 이름 규칙: 영문/숫자로 시작 · 소문자·숫자·-·_ 만. `web` 은 관례상 최초
|
|
19
|
+
// 앱이라 `gaon new` 가 이미 만든다 — 존재하면 여기서 실패한다(중복 스캐폴드
|
|
20
|
+
// 방지 · Rails 관례).
|
|
21
|
+
/** 이름 유효성 — apps/<name>/ 이 안전하고 URL 프리픽스로도 무리 없는 부분집합. */
|
|
22
|
+
export function validateAppName(name) {
|
|
23
|
+
if (!name) {
|
|
24
|
+
throw new Error(`앱 이름이 비어 있습니다.\n` +
|
|
25
|
+
` → 예: gaon g app admin (apps/admin/ 생성)`);
|
|
26
|
+
}
|
|
27
|
+
if (name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
28
|
+
throw new Error(`앱 이름에 '/' '\\\\' '..' 는 사용할 수 없습니다: ${name}`);
|
|
29
|
+
}
|
|
30
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
|
31
|
+
throw new Error(`앱 이름은 소문자/숫자로 시작해 소문자·숫자·-·_ 만 씁니다: ${name}\n` +
|
|
32
|
+
` → 예: admin · api · marketing`);
|
|
33
|
+
}
|
|
34
|
+
// 예약 이름 — URL 프리픽스가 될 이름과 충돌을 피한다.
|
|
35
|
+
const reserved = new Set([
|
|
36
|
+
'api', // 관례상 API 앱 이름으로 자주 쓰이나 예약 여부는 후속. v1 은 허용.
|
|
37
|
+
// 시스템/디자인 예약 슬롯 — 확장 대비.
|
|
38
|
+
'_gaon',
|
|
39
|
+
'.gaon',
|
|
40
|
+
'node_modules',
|
|
41
|
+
'dist',
|
|
42
|
+
'build',
|
|
43
|
+
'__test__',
|
|
44
|
+
]);
|
|
45
|
+
if (reserved.has(name) && name !== 'api') {
|
|
46
|
+
throw new Error(`앱 이름 '${name}' 은 예약되어 있습니다.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** 앱 스캐폴드 파일 계획. 순수 함수 — 실제 쓰기는 writeScaffold 가 담당. */
|
|
50
|
+
export function appScaffoldFiles(name) {
|
|
51
|
+
validateAppName(name);
|
|
52
|
+
const files = [];
|
|
53
|
+
// 1) routes.ts — 관례상 GET / + /health 두 라우트로 시작한다.
|
|
54
|
+
files.push({
|
|
55
|
+
path: `apps/${name}/routes.ts`,
|
|
56
|
+
contents: [
|
|
57
|
+
`// ${name} 앱 라우트 — apps/${name}/routes.ts (v0.16 §3.3).`,
|
|
58
|
+
`// 앱 폴더명이 URL 프리픽스가 된다: '/${name}' 아래로 서빙된다.`,
|
|
59
|
+
`// 개발 모드에서는 '${name}.localhost' 로도 접근된다(§3.3 · v0.11 확정).`,
|
|
60
|
+
`import { routes } from 'gaonjs/web'`,
|
|
61
|
+
``,
|
|
62
|
+
`export default routes((r) => {`,
|
|
63
|
+
` r.get('/', 'home#index') // GET /${name}/ (또는 http://${name}.localhost/)`,
|
|
64
|
+
` r.get('/health', 'home#health') // GET /${name}/health · JSON { ok: true }`,
|
|
65
|
+
`})`,
|
|
66
|
+
``,
|
|
67
|
+
].join('\n'),
|
|
68
|
+
});
|
|
69
|
+
// 2) app.config.ts — 잘 지은 앱은 거의 비어 있어야 한다(§1.1 ②).
|
|
70
|
+
files.push({
|
|
71
|
+
path: `apps/${name}/app.config.ts`,
|
|
72
|
+
contents: [
|
|
73
|
+
`// ${name} 앱 설정 — apps/${name}/app.config.ts.`,
|
|
74
|
+
`// 잘 지은 앱의 app.config.ts 는 거의 비어 있다(v0.16 §1.1 ②).`,
|
|
75
|
+
`// 관례에서 벗어날 때만 값을 채운다:`,
|
|
76
|
+
`// hosts: ['${name}.example.com'] // 도메인 바인딩(개발은 ${name}.localhost 자동)`,
|
|
77
|
+
`// session: { cookie: '${name}_session' } // 세션 옵션 덮어쓰기`,
|
|
78
|
+
`import { defineAppConfig } from 'gaonjs/config'`,
|
|
79
|
+
``,
|
|
80
|
+
`export default defineAppConfig({})`,
|
|
81
|
+
``,
|
|
82
|
+
].join('\n'),
|
|
83
|
+
});
|
|
84
|
+
// 3) controllers/home.ts — 페이지 + JSON 액션 두 대표 패턴(errata E-3).
|
|
85
|
+
files.push({
|
|
86
|
+
path: `apps/${name}/controllers/home.ts`,
|
|
87
|
+
contents: [
|
|
88
|
+
`// home 컨트롤러 — apps/${name}/controllers/home.ts.`,
|
|
89
|
+
`// 페이지 액션(this.render)과 JSON 액션(반환값 = 응답 · errata E-3) 예제.`,
|
|
90
|
+
`import { controller } from 'gaonjs/web'`,
|
|
91
|
+
``,
|
|
92
|
+
`export default controller({`,
|
|
93
|
+
` // GET /${name}/ — Inertia SPA 홈. this.render 는 Vue 페이지로 넘긴다.`,
|
|
94
|
+
` async index() {`,
|
|
95
|
+
` return this.render('Home/Index', {`,
|
|
96
|
+
` app: '${name}',`,
|
|
97
|
+
` })`,
|
|
98
|
+
` },`,
|
|
99
|
+
``,
|
|
100
|
+
` // GET /${name}/health — JSON 액션(errata E-3). 반환값이 곧 응답.`,
|
|
101
|
+
` async health() {`,
|
|
102
|
+
` return { ok: true, app: '${name}' }`,
|
|
103
|
+
` },`,
|
|
104
|
+
`})`,
|
|
105
|
+
``,
|
|
106
|
+
].join('\n'),
|
|
107
|
+
});
|
|
108
|
+
// 4) pages/Home/Index.vue — Inertia 페이지. pageProps 브리지(§6.2).
|
|
109
|
+
files.push({
|
|
110
|
+
path: `apps/${name}/pages/Home/Index.vue`,
|
|
111
|
+
contents: [
|
|
112
|
+
`<script setup lang="ts">`,
|
|
113
|
+
`import { pageProps } from 'gaonjs/vue'`,
|
|
114
|
+
``,
|
|
115
|
+
`// home#index 의 render props — Serialized<> 로 넘어온다(§6.2).`,
|
|
116
|
+
`const props = pageProps<'home#index'>()`,
|
|
117
|
+
`</script>`,
|
|
118
|
+
``,
|
|
119
|
+
`<template>`,
|
|
120
|
+
` <main class="app-home">`,
|
|
121
|
+
` <h1>{{ props.app }} 앱</h1>`,
|
|
122
|
+
` <p>gaon g app ${name} 이 방금 이 앱을 만들었습니다.</p>`,
|
|
123
|
+
` <p>URL 프리픽스: <code>/${name}</code> · 로컬 도메인: <code>http://${name}.localhost</code></p>`,
|
|
124
|
+
` </main>`,
|
|
125
|
+
`</template>`,
|
|
126
|
+
``,
|
|
127
|
+
`<style scoped>`,
|
|
128
|
+
`.app-home {`,
|
|
129
|
+
` max-width: 640px;`,
|
|
130
|
+
` margin: 4rem auto;`,
|
|
131
|
+
` padding: 0 1rem;`,
|
|
132
|
+
` font-family: system-ui, -apple-system, sans-serif;`,
|
|
133
|
+
` line-height: 1.6;`,
|
|
134
|
+
`}`,
|
|
135
|
+
`</style>`,
|
|
136
|
+
``,
|
|
137
|
+
].join('\n'),
|
|
138
|
+
});
|
|
139
|
+
// 5) layouts/Default.vue — E-5 §2.3 · 파일 존재 = 등록. 앱별 레이아웃.
|
|
140
|
+
files.push({
|
|
141
|
+
path: `apps/${name}/layouts/Default.vue`,
|
|
142
|
+
contents: [
|
|
143
|
+
`<script setup lang="ts">`,
|
|
144
|
+
`// ${name} 앱 기본 레이아웃 — apps/${name}/layouts/Default.vue (errata E-5 §2.3).`,
|
|
145
|
+
`// 파일이 존재하면 이 앱의 모든 페이지에 자동 적용된다(파일 존재 = 등록).`,
|
|
146
|
+
`</script>`,
|
|
147
|
+
``,
|
|
148
|
+
`<template>`,
|
|
149
|
+
` <div class="layout">`,
|
|
150
|
+
` <header class="layout-header"><strong>${name}</strong></header>`,
|
|
151
|
+
` <slot />`,
|
|
152
|
+
` </div>`,
|
|
153
|
+
`</template>`,
|
|
154
|
+
``,
|
|
155
|
+
`<style scoped>`,
|
|
156
|
+
`.layout { min-height: 100vh; }`,
|
|
157
|
+
`.layout-header { padding: 1rem; background: #f6f8fa; border-bottom: 1px solid #e1e4e8; }`,
|
|
158
|
+
`</style>`,
|
|
159
|
+
``,
|
|
160
|
+
].join('\n'),
|
|
161
|
+
});
|
|
162
|
+
// 6-8) 관례 폴더 유지 — git 이 빈 폴더를 추적하지 않아 .gitkeep 을 둔다.
|
|
163
|
+
// components · composables · channels 는 앱이 클수록 자주 채워지는
|
|
164
|
+
// 세 자리다(E-5 §2.2 · M6 실시간).
|
|
165
|
+
for (const sub of ['components', 'composables', 'channels']) {
|
|
166
|
+
files.push({
|
|
167
|
+
path: `apps/${name}/${sub}/.gitkeep`,
|
|
168
|
+
contents: '',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
return files;
|
|
172
|
+
}
|
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
* @param app 대상 앱 폴더(apps/<app>/).
|
|
19
19
|
*/
|
|
20
20
|
export function controllerScaffold(names, app) {
|
|
21
|
-
const { pascal,
|
|
21
|
+
const { pascal, plural } = names;
|
|
22
22
|
const lines = [
|
|
23
23
|
`// ${plural} 컨트롤러 — gaon g controller (M9-B).`,
|
|
24
24
|
`// 페이지 액션(this.render)과 JSON 액션(반환값=응답 · errata E-3) 예시를 담는다.`,
|
|
25
25
|
`// 응답 종류를 한 컨트롤러에서 섞으면 doctor 가 경고한다(§C) — 규모가 커지면 분리.`,
|
|
26
26
|
`import { controller } from 'gaonjs/web'`,
|
|
27
|
-
`import { ${pascal} } from '../../../domain/models/${
|
|
27
|
+
`import { ${pascal} } from '../../../domain/models/${pascal}.js'`,
|
|
28
28
|
``,
|
|
29
29
|
`export default controller({`,
|
|
30
30
|
` // GET /${plural} — 목록 페이지 (Inertia render).`,
|