@fuzionx/framework 0.1.43 → 0.1.44
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/README.md +501 -501
- package/bin/fx.js +12 -12
- package/cli/db-sync.js +100 -100
- package/cli/index.js +494 -494
- package/cli/templates/make/app/controllers/HomeController.js +14 -14
- package/cli/templates/make/app/routes/api.js +7 -7
- package/cli/templates/make/app/routes/web.js +5 -5
- package/cli/templates/make/app/views/default/errors/404.html +11 -11
- package/cli/templates/make/app/views/default/errors/500.html +14 -14
- package/cli/templates/make/app/views/default/layouts/main.html +22 -22
- package/cli/templates/make/app/views/default/pages/home.html +11 -11
- package/cli/templates/make/controller.js.tpl +40 -40
- package/cli/templates/make/event.js.tpl +8 -8
- package/cli/templates/make/job.js.tpl +10 -10
- package/cli/templates/make/middleware.js.tpl +10 -10
- package/cli/templates/make/model.js.tpl +15 -15
- package/cli/templates/make/service.js.tpl +15 -15
- package/cli/templates/make/task.js.tpl +15 -15
- package/cli/templates/make/test.js.tpl +7 -7
- package/cli/templates/make/worker.js.tpl +14 -14
- package/cli/templates/make/ws.js.tpl +18 -18
- package/index.js +67 -67
- package/lib/core/AppError.js +46 -46
- package/lib/core/Application.js +1006 -1006
- package/lib/core/AutoLoader.js +227 -227
- package/lib/core/Base.js +64 -64
- package/lib/core/Config.js +331 -331
- package/lib/core/Context.js +484 -484
- package/lib/database/ConnectionManager.js +208 -208
- package/lib/database/MariaModel.js +29 -29
- package/lib/database/Model.js +247 -247
- package/lib/database/ModelRegistry.js +72 -72
- package/lib/database/MongoModel.js +232 -232
- package/lib/database/Pagination.js +37 -37
- package/lib/database/PostgreModel.js +29 -29
- package/lib/database/QueryBuilder.js +172 -172
- package/lib/database/SQLiteModel.js +27 -27
- package/lib/database/SqlModel.js +257 -257
- package/lib/database/SqlQueryBuilder.js +332 -332
- package/lib/helpers/CryptoHelper.js +48 -48
- package/lib/helpers/FileHelper.js +61 -61
- package/lib/helpers/HashHelper.js +39 -39
- package/lib/helpers/I18nHelper.js +174 -174
- package/lib/helpers/Logger.js +108 -108
- package/lib/helpers/MediaHelper.js +84 -84
- package/lib/http/Controller.js +34 -34
- package/lib/http/ErrorHandler.js +136 -136
- package/lib/http/Middleware.js +43 -43
- package/lib/http/Router.js +109 -109
- package/lib/http/Validation.js +125 -125
- package/lib/middleware/apiAuth.js +79 -79
- package/lib/middleware/auth.js +42 -42
- package/lib/middleware/bodyParser.js +19 -19
- package/lib/middleware/cors.js +47 -47
- package/lib/middleware/csrf.js +32 -32
- package/lib/middleware/index.js +13 -13
- package/lib/middleware/session.js +27 -27
- package/lib/middleware/theme.js +20 -20
- package/lib/realtime/RoomManager.js +85 -85
- package/lib/realtime/WsHandler.js +107 -107
- package/lib/schedule/Job.js +38 -38
- package/lib/schedule/Queue.js +103 -103
- package/lib/schedule/Scheduler.js +171 -171
- package/lib/schedule/Task.js +39 -39
- package/lib/schedule/WorkerPool.js +225 -225
- package/lib/services/EventBus.js +94 -94
- package/lib/services/Service.js +261 -261
- package/lib/services/Storage.js +112 -112
- package/lib/utilities/ArrUtil.js +112 -112
- package/lib/utilities/DateUtil.js +98 -98
- package/lib/utilities/FunctionUtil.js +119 -119
- package/lib/utilities/NumUtil.js +75 -75
- package/lib/utilities/ObjectUtil.js +170 -170
- package/lib/utilities/PaginationUtil.js +81 -81
- package/lib/utilities/StrUtil.js +105 -105
- package/lib/utilities/index.js +18 -18
- package/lib/view/OpenAPI.js +231 -231
- package/lib/view/View.js +83 -83
- package/package.json +2 -2
- package/testing/index.js +232 -232
package/cli/index.js
CHANGED
|
@@ -1,494 +1,494 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CLI — fx 명령어 핸들러
|
|
3
|
-
*
|
|
4
|
-
* fx make:app --type=ssr|spa 앱 디렉토리 생성 (고정 이름)
|
|
5
|
-
* fx make:controller <Name> 컨트롤러 생성
|
|
6
|
-
* fx make:service <Name> 서비스 생성
|
|
7
|
-
* fx make:model <Name> 모델 생성
|
|
8
|
-
* fx make:middleware <Name> 미들웨어 생성
|
|
9
|
-
* fx make:job <Name> Job 생성
|
|
10
|
-
* fx make:task <Name> Task 생성
|
|
11
|
-
* fx make:ws <Name> WsHandler 생성
|
|
12
|
-
* fx make:event <Name> 이벤트 핸들러 생성
|
|
13
|
-
* fx make:worker <Name> Worker 생성
|
|
14
|
-
* fx make:test <Name> 테스트 생성
|
|
15
|
-
* fx dev:spa FuzionX + Vite 동시 실행
|
|
16
|
-
* fx build:spa Vite 프로덕션 빌드
|
|
17
|
-
*
|
|
18
|
-
* @see docs/framework/16-cli.md
|
|
19
|
-
*/
|
|
20
|
-
import { promises as fs } from 'node:fs';
|
|
21
|
-
import path from 'node:path';
|
|
22
|
-
import { fileURLToPath } from 'node:url';
|
|
23
|
-
import { pathToFileURL } from 'node:url';
|
|
24
|
-
|
|
25
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
-
const TPL_DIR = path.join(__dirname, 'templates');
|
|
27
|
-
|
|
28
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
29
|
-
// Template Engine
|
|
30
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* 간단 템플릿 치환 — {{varName}} → value
|
|
34
|
-
* @param {string} template
|
|
35
|
-
* @param {object} vars - { Name, name, nameLower, tableName, dbName }
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function render(template, vars) {
|
|
39
|
-
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? '');
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* 템플릿 파일 읽기 + 치환
|
|
44
|
-
* @param {string} relativePath - templates/ 기준 상대 경로
|
|
45
|
-
* @param {object} vars
|
|
46
|
-
* @returns {Promise<string>}
|
|
47
|
-
*/
|
|
48
|
-
async function loadTemplate(relativePath, vars = {}) {
|
|
49
|
-
const content = await fs.readFile(path.join(TPL_DIR, relativePath), 'utf-8');
|
|
50
|
-
return render(content, vars);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
54
|
-
// fx make:* — 코드 생성
|
|
55
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
56
|
-
|
|
57
|
-
/** 앱별 파일 (--app 필수) */
|
|
58
|
-
const APP_TYPE_DIRS = {
|
|
59
|
-
controller: 'controllers',
|
|
60
|
-
service: 'services',
|
|
61
|
-
middleware: 'middleware',
|
|
62
|
-
ws: 'ws',
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
/** 공유 파일 (앱 무관) */
|
|
66
|
-
const SHARED_TYPE_DIRS = {
|
|
67
|
-
model: 'database/models',
|
|
68
|
-
job: 'shared/jobs',
|
|
69
|
-
task: 'shared/jobs',
|
|
70
|
-
event: 'shared/events',
|
|
71
|
-
worker: 'shared/workers',
|
|
72
|
-
test: 'tests',
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const TYPE_SUFFIXES = {
|
|
76
|
-
controller: 'Controller',
|
|
77
|
-
service: 'Service',
|
|
78
|
-
model: '',
|
|
79
|
-
middleware: 'Middleware',
|
|
80
|
-
job: 'Job',
|
|
81
|
-
task: '',
|
|
82
|
-
ws: 'Handler',
|
|
83
|
-
event: '',
|
|
84
|
-
worker: '',
|
|
85
|
-
test: '.test',
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* fx make:<type> <Name> [--app=<appName>] — 코드 생성
|
|
90
|
-
* @param {string} type - 'controller', 'service', 'model', 등
|
|
91
|
-
* @param {string} name - PascalCase 이름 (e.g. 'User')
|
|
92
|
-
* @param {string} [baseDir='.'] - 프로젝트 루트
|
|
93
|
-
* @param {string} [appName] - 앱 이름 (controller/service/middleware/ws 시 필수)
|
|
94
|
-
* @returns {Promise<string>} - 생성된 파일 경로
|
|
95
|
-
*/
|
|
96
|
-
export async function makeFile(type, name, baseDir = '.', appName) {
|
|
97
|
-
let dir;
|
|
98
|
-
if (APP_TYPE_DIRS[type]) {
|
|
99
|
-
if (!appName) throw new Error(`--app option required for make:${type} (e.g. fx make:${type} ${name} --app=fuzionx)`);
|
|
100
|
-
dir = `app/${appName}/${APP_TYPE_DIRS[type]}`;
|
|
101
|
-
} else if (SHARED_TYPE_DIRS[type]) {
|
|
102
|
-
dir = SHARED_TYPE_DIRS[type];
|
|
103
|
-
} else {
|
|
104
|
-
throw new Error(`Unknown type: ${type}`);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const suffix = TYPE_SUFFIXES[type];
|
|
108
|
-
const fileName = `${name}${suffix}.js`;
|
|
109
|
-
const filePath = path.join(baseDir, dir, fileName);
|
|
110
|
-
|
|
111
|
-
const vars = {
|
|
112
|
-
Name: name,
|
|
113
|
-
name: name,
|
|
114
|
-
nameLower: name.toLowerCase(),
|
|
115
|
-
tableName: name.toLowerCase() + 's',
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
const content = await loadTemplate(`make/${type}.js.tpl`, vars);
|
|
119
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
120
|
-
await fs.writeFile(filePath, content);
|
|
121
|
-
|
|
122
|
-
return filePath;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
126
|
-
// 유틸리티
|
|
127
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
128
|
-
|
|
129
|
-
/** 디렉토리 재귀 복사 (템플릿 → 프로젝트) */
|
|
130
|
-
async function copyDirRecursive(src, dst) {
|
|
131
|
-
await fs.mkdir(dst, { recursive: true });
|
|
132
|
-
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
133
|
-
for (const entry of entries) {
|
|
134
|
-
const srcPath = path.join(src, entry.name);
|
|
135
|
-
const dstPath = path.join(dst, entry.name);
|
|
136
|
-
if (entry.isDirectory()) {
|
|
137
|
-
await copyDirRecursive(srcPath, dstPath);
|
|
138
|
-
} else {
|
|
139
|
-
await fs.copyFile(srcPath, dstPath);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
145
|
-
// CLI 엔트리
|
|
146
|
-
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* CLI 엔트리 (fx 명령어)
|
|
150
|
-
* @param {string[]} args - process.argv.slice(2)
|
|
151
|
-
*/
|
|
152
|
-
export async function run(args) {
|
|
153
|
-
const [command, ...rest] = args;
|
|
154
|
-
|
|
155
|
-
// ── fx new → create-fuzionx 안내 ──
|
|
156
|
-
if (command === 'new') {
|
|
157
|
-
console.log(`\n 프로젝트 생성은 create-fuzionx를 사용하세요:\n`);
|
|
158
|
-
console.log(` npx create-fuzionx <name>\n`);
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (command === 'make:app') {
|
|
163
|
-
const typeFlag = rest.find(a => a.startsWith('--type='));
|
|
164
|
-
const type = typeFlag?.split('=')[1] || rest[0];
|
|
165
|
-
const validTypes = ['ssr', 'spa'];
|
|
166
|
-
|
|
167
|
-
if (!type || !validTypes.includes(type)) {
|
|
168
|
-
console.error(`Usage: fx make:app --type=ssr|spa`);
|
|
169
|
-
console.error(` Available types: ${validTypes.join(', ')}`);
|
|
170
|
-
process.exit(1);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// 앱 이름은 타입에 따라 고정
|
|
174
|
-
const appName = type;
|
|
175
|
-
const appDir = path.join('.', 'app', appName);
|
|
176
|
-
|
|
177
|
-
// 기존 앱 확인
|
|
178
|
-
try {
|
|
179
|
-
await fs.access(appDir);
|
|
180
|
-
console.error(`❌ app/${appName}/ already exists.`);
|
|
181
|
-
process.exit(1);
|
|
182
|
-
} catch { /* 없으면 정상 */ }
|
|
183
|
-
|
|
184
|
-
// 빈 디렉토리 생성
|
|
185
|
-
for (const d of ['services', 'middleware', 'ws']) {
|
|
186
|
-
const fullDir = path.join(appDir, d);
|
|
187
|
-
await fs.mkdir(fullDir, { recursive: true });
|
|
188
|
-
await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// 기본 파일 복사 (HomeController, routes, views)
|
|
192
|
-
const appTplDir = path.join(TPL_DIR, 'make/app');
|
|
193
|
-
await copyDirRecursive(appTplDir, appDir);
|
|
194
|
-
|
|
195
|
-
console.log(`✅ Created app/${appName}/ (type: ${type})`);
|
|
196
|
-
console.log(`\n fuzionx.yaml의 apps에 추가하세요:`);
|
|
197
|
-
console.log(` "127.0.0.1:49080": ${appName}\n`);
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (command?.startsWith('make:')) {
|
|
202
|
-
const type = command.replace('make:', '');
|
|
203
|
-
const name = rest[0];
|
|
204
|
-
if (!name) { console.error(`Usage: fx ${command} <Name> [--app=<appName>]`); process.exit(1); }
|
|
205
|
-
const appFlag = rest.find(a => a.startsWith('--app='));
|
|
206
|
-
const appName = appFlag?.split('=')[1] || undefined;
|
|
207
|
-
const file = await makeFile(type, name, '.', appName);
|
|
208
|
-
console.log(`✅ Created ${file}`);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ── fx dev — 개발 서버 (Node --watch) ──
|
|
213
|
-
if (command === 'dev') {
|
|
214
|
-
const { execSync } = await import('node:child_process');
|
|
215
|
-
const port = rest.find(a => a.startsWith('--port='))?.split('=')[1] || '';
|
|
216
|
-
const entry = 'app.js';
|
|
217
|
-
const env = port ? `PORT=${port} ` : '';
|
|
218
|
-
console.log(`🚀 Starting dev server...`);
|
|
219
|
-
try {
|
|
220
|
-
execSync(`${env}node --watch ${entry}`, { stdio: 'inherit', cwd: process.cwd() });
|
|
221
|
-
} catch {}
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── fx dev:spa — FuzionX + Vite HMR 동시 실행 ──
|
|
226
|
-
if (command === 'dev:spa') {
|
|
227
|
-
const { execSync } = await import('node:child_process');
|
|
228
|
-
const spaDir = path.resolve('app/spa/views/default/spa');
|
|
229
|
-
try {
|
|
230
|
-
await fs.access(spaDir);
|
|
231
|
-
} catch {
|
|
232
|
-
console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
|
|
233
|
-
console.error(' fx make:app --type=spa 로 SPA 앱을 먼저 생성하세요.');
|
|
234
|
-
process.exit(1);
|
|
235
|
-
}
|
|
236
|
-
console.log(`🚀 Starting dev server + Vite HMR...`);
|
|
237
|
-
try {
|
|
238
|
-
execSync(
|
|
239
|
-
'npx concurrently "node --watch app.js" "cd app/spa/views/default/spa && npx vite"',
|
|
240
|
-
{ stdio: 'inherit', cwd: process.cwd() },
|
|
241
|
-
);
|
|
242
|
-
} catch {}
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ── fx build:spa — Vite 프로덕션 빌드 ──
|
|
247
|
-
if (command === 'build:spa') {
|
|
248
|
-
const { execSync } = await import('node:child_process');
|
|
249
|
-
const spaDir = path.resolve('app/spa/views/default/spa');
|
|
250
|
-
try {
|
|
251
|
-
await fs.access(spaDir);
|
|
252
|
-
} catch {
|
|
253
|
-
console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
|
|
254
|
-
process.exit(1);
|
|
255
|
-
}
|
|
256
|
-
console.log(`📦 Building SPA for production...`);
|
|
257
|
-
try {
|
|
258
|
-
execSync('npx vite build', { stdio: 'inherit', cwd: spaDir });
|
|
259
|
-
console.log('\n✅ Build complete → public/dist/');
|
|
260
|
-
} catch { process.exit(1); }
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ── fx stop — 서버 종료 ──
|
|
265
|
-
if (command === 'stop') {
|
|
266
|
-
const pidFile = path.resolve('fuzionx.pid');
|
|
267
|
-
try {
|
|
268
|
-
const pid = parseInt(await fs.readFile(pidFile, 'utf-8'), 10);
|
|
269
|
-
console.log(`🛑 Stopping PID ${pid}...`);
|
|
270
|
-
process.kill(pid, 'SIGTERM');
|
|
271
|
-
for (let i = 0; i < 50; i++) {
|
|
272
|
-
await new Promise(r => setTimeout(r, 100));
|
|
273
|
-
try { process.kill(pid, 0); } catch { break; }
|
|
274
|
-
}
|
|
275
|
-
await fs.unlink(pidFile).catch(() => {});
|
|
276
|
-
console.log('✅ Server stopped.');
|
|
277
|
-
} catch (err) {
|
|
278
|
-
if (err.code === 'ENOENT') {
|
|
279
|
-
console.log('⚠️ fuzionx.pid 없음 — 서버가 실행 중이 아닙니다.');
|
|
280
|
-
} else if (err.code === 'ESRCH') {
|
|
281
|
-
await fs.unlink(pidFile).catch(() => {});
|
|
282
|
-
console.log('⚠️ PID 프로세스가 이미 종료됨. PID 파일 삭제.');
|
|
283
|
-
} else {
|
|
284
|
-
console.error('❌ 종료 실패:', err.message);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// ── fx restart — 서버 재시작 ──
|
|
291
|
-
if (command === 'restart') {
|
|
292
|
-
const pidFile = path.resolve('fuzionx.pid');
|
|
293
|
-
try {
|
|
294
|
-
const pid = parseInt(await fs.readFile(pidFile, 'utf-8'), 10);
|
|
295
|
-
console.log(`🔄 Stopping PID ${pid}...`);
|
|
296
|
-
process.kill(pid, 'SIGTERM');
|
|
297
|
-
for (let i = 0; i < 50; i++) {
|
|
298
|
-
await new Promise(r => setTimeout(r, 100));
|
|
299
|
-
try { process.kill(pid, 0); } catch { break; }
|
|
300
|
-
}
|
|
301
|
-
} catch (err) {
|
|
302
|
-
if (err.code === 'ENOENT') {
|
|
303
|
-
console.log('⚠️ fuzionx.pid 없음 — 새로 시작합니다.');
|
|
304
|
-
} else if (err.code === 'ESRCH') {
|
|
305
|
-
console.log('⚠️ PID 프로세스가 이미 종료됨.');
|
|
306
|
-
} else {
|
|
307
|
-
console.error('❌ 종료 실패:', err.message);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
// 백그라운드로 재시작
|
|
311
|
-
const { spawn } = await import('node:child_process');
|
|
312
|
-
const logDir = path.resolve('storage/logs');
|
|
313
|
-
await fs.mkdir(logDir, { recursive: true });
|
|
314
|
-
const logFile = path.join(logDir, 'server.log');
|
|
315
|
-
const { openSync } = await import('node:fs');
|
|
316
|
-
const out = openSync(logFile, 'a');
|
|
317
|
-
const child = spawn('node', ['app.js'], {
|
|
318
|
-
cwd: process.cwd(),
|
|
319
|
-
detached: true,
|
|
320
|
-
stdio: ['ignore', out, out],
|
|
321
|
-
});
|
|
322
|
-
child.unref();
|
|
323
|
-
console.log(`🚀 Restarted (PID ${child.pid}) — logs: storage/logs/server.log`);
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// ── fx test — 테스트 실행 ──
|
|
328
|
-
if (command === 'test') {
|
|
329
|
-
const { execSync } = await import('node:child_process');
|
|
330
|
-
try {
|
|
331
|
-
execSync('npx vitest run', { stdio: 'inherit', cwd: process.cwd() });
|
|
332
|
-
} catch { process.exit(1); }
|
|
333
|
-
return;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// ── fx routes — 라우트 테이블 출력 ──
|
|
337
|
-
if (command === 'routes') {
|
|
338
|
-
try {
|
|
339
|
-
const appMod = await import(pathToFileURL(path.resolve('app.js')).href);
|
|
340
|
-
const app = appMod.default || appMod.app;
|
|
341
|
-
if (!app?._appRegistry) { console.error('Cannot load app routes'); return; }
|
|
342
|
-
console.log('');
|
|
343
|
-
console.log(' APP METHOD PATH HANDLER MIDDLEWARE');
|
|
344
|
-
console.log(' ────── ────── ───── ──────── ──────────');
|
|
345
|
-
for (const [appName, appEntry] of app._appRegistry) {
|
|
346
|
-
const routes = appEntry.router.getRoutes();
|
|
347
|
-
for (const r of routes) {
|
|
348
|
-
const aName = appName.padEnd(10);
|
|
349
|
-
const method = r.method.padEnd(8);
|
|
350
|
-
const rPath = r.path.padEnd(22);
|
|
351
|
-
const handler = r.handler?.__handler__
|
|
352
|
-
? `${r.handler.controller?.name || ''}.${r.handler.method}`
|
|
353
|
-
: (typeof r.handler === 'function' ? r.handler.name || 'anonymous' : '');
|
|
354
|
-
const mw = (r.middleware || []).join(', ');
|
|
355
|
-
console.log(` ${aName} ${method} ${rPath} ${handler.padEnd(26)} ${mw}`);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
console.log('');
|
|
359
|
-
} catch (err) { console.error('Failed to load routes:', err.message); }
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// ── fx config — fuzionx.yaml 출력 ──
|
|
364
|
-
if (command === 'config') {
|
|
365
|
-
try {
|
|
366
|
-
const configPath = path.resolve('fuzionx.yaml');
|
|
367
|
-
const content = await fs.readFile(configPath, 'utf-8');
|
|
368
|
-
console.log('\n📄 fuzionx.yaml:\n');
|
|
369
|
-
console.log(content);
|
|
370
|
-
} catch (err) { console.error('Cannot read fuzionx.yaml:', err.message); }
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// ── fx db:sync — 모델 ↔ DB 스키마 diff ──
|
|
375
|
-
if (command === 'db:sync') {
|
|
376
|
-
try {
|
|
377
|
-
const modelsDir = path.resolve('database/models');
|
|
378
|
-
const files = await fs.readdir(modelsDir);
|
|
379
|
-
const jsFiles = files.filter(f => f.endsWith('.js'));
|
|
380
|
-
const apply = rest.includes('--apply');
|
|
381
|
-
|
|
382
|
-
// fuzionx.yaml에서 DB 경로 읽기
|
|
383
|
-
let dbPath = './storage/database.sqlite';
|
|
384
|
-
try {
|
|
385
|
-
const yaml = await fs.readFile(path.resolve('fuzionx.yaml'), 'utf8');
|
|
386
|
-
const dbMatch = yaml.match(/database:\s*(.+\.sqlite)/);
|
|
387
|
-
if (dbMatch) dbPath = dbMatch[1].trim();
|
|
388
|
-
} catch {}
|
|
389
|
-
|
|
390
|
-
console.log('\n📊 Model Schema Status:\n');
|
|
391
|
-
const models = [];
|
|
392
|
-
|
|
393
|
-
for (const file of jsFiles) {
|
|
394
|
-
const mod = await import(pathToFileURL(path.join(modelsDir, file)).href);
|
|
395
|
-
const Model = mod.default;
|
|
396
|
-
if (!Model) continue;
|
|
397
|
-
const name = path.basename(file, '.js');
|
|
398
|
-
const table = Model.table || name.toLowerCase() + 's';
|
|
399
|
-
const cols = Model.columns || {};
|
|
400
|
-
models.push({ name, table, cols });
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (apply) {
|
|
404
|
-
// SQLite DB 열기
|
|
405
|
-
const resolvedDbPath = path.resolve(dbPath);
|
|
406
|
-
await fs.mkdir(path.dirname(resolvedDbPath), { recursive: true });
|
|
407
|
-
const { default: Database } = await import('better-sqlite3');
|
|
408
|
-
const db = new Database(resolvedDbPath);
|
|
409
|
-
|
|
410
|
-
for (const { name, table, cols } of models) {
|
|
411
|
-
const colDefs = [];
|
|
412
|
-
for (const [col, def] of Object.entries(cols)) {
|
|
413
|
-
if (def.type === 'increments') {
|
|
414
|
-
colDefs.push(`\`${col}\` INTEGER PRIMARY KEY AUTOINCREMENT`);
|
|
415
|
-
} else if (def.type === 'integer') {
|
|
416
|
-
colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
|
|
417
|
-
} else if (def.type === 'text') {
|
|
418
|
-
colDefs.push(`\`${col}\` TEXT NOT NULL DEFAULT ''`);
|
|
419
|
-
} else if (def.type === 'datetime') {
|
|
420
|
-
colDefs.push(`\`${col}\` DATETIME DEFAULT CURRENT_TIMESTAMP`);
|
|
421
|
-
} else if (def.type === 'boolean') {
|
|
422
|
-
colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
|
|
423
|
-
} else {
|
|
424
|
-
// string, etc.
|
|
425
|
-
const unique = def.unique ? ' UNIQUE' : '';
|
|
426
|
-
const dflt = def.default != null ? ` DEFAULT '${def.default}'` : " DEFAULT ''";
|
|
427
|
-
colDefs.push(`\`${col}\` TEXT NOT NULL${dflt}${unique}`);
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
const sql = `CREATE TABLE IF NOT EXISTS \`${table}\` (\n ${colDefs.join(',\n ')}\n)`;
|
|
431
|
-
db.exec(sql);
|
|
432
|
-
|
|
433
|
-
// ── 기존 테이블에 누락된 컬럼 추가 (ALTER TABLE) ──
|
|
434
|
-
const existingCols = db.pragma(`table_info(${table})`).map(c => c.name);
|
|
435
|
-
let addedCols = 0;
|
|
436
|
-
for (const [col, def] of Object.entries(cols)) {
|
|
437
|
-
if (existingCols.includes(col)) continue;
|
|
438
|
-
|
|
439
|
-
let colSql;
|
|
440
|
-
if (def.type === 'integer') {
|
|
441
|
-
colSql = `INTEGER NOT NULL DEFAULT 0`;
|
|
442
|
-
} else if (def.type === 'text') {
|
|
443
|
-
colSql = `TEXT NOT NULL DEFAULT ''`;
|
|
444
|
-
} else if (def.type === 'datetime') {
|
|
445
|
-
colSql = `DATETIME DEFAULT NULL`;
|
|
446
|
-
} else if (def.type === 'boolean') {
|
|
447
|
-
colSql = `INTEGER NOT NULL DEFAULT 0`;
|
|
448
|
-
} else {
|
|
449
|
-
const dflt = def.default != null ? `'${def.default}'` : "''";
|
|
450
|
-
colSql = `TEXT DEFAULT ${dflt}`;
|
|
451
|
-
}
|
|
452
|
-
db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${col}\` ${colSql}`);
|
|
453
|
-
addedCols++;
|
|
454
|
-
}
|
|
455
|
-
const status = addedCols > 0 ? `✅ synced (+${addedCols} columns)` : '✅ synced';
|
|
456
|
-
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ${status}`);
|
|
457
|
-
}
|
|
458
|
-
db.close();
|
|
459
|
-
console.log(`\n Database: ${path.resolve(dbPath)}`);
|
|
460
|
-
} else {
|
|
461
|
-
for (const { name, table, cols } of models) {
|
|
462
|
-
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ⏳ pending`);
|
|
463
|
-
}
|
|
464
|
-
console.log('\n Run with --apply to sync changes to database.');
|
|
465
|
-
}
|
|
466
|
-
console.log('');
|
|
467
|
-
} catch (err) { console.error('db:sync error:', err.message); }
|
|
468
|
-
return;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
console.log(`
|
|
472
|
-
npx create-fuzionx <name> Create new project
|
|
473
|
-
fx make:app --type=ssr|spa Create app (fixed name: app/ssr or app/spa)
|
|
474
|
-
fx make:controller <Name> --app= Create controller (app-specific)
|
|
475
|
-
fx make:service <Name> --app= Create service (app-specific)
|
|
476
|
-
fx make:model <Name> Create model (database/models)
|
|
477
|
-
fx make:middleware <Name> --app= Create middleware (app-specific)
|
|
478
|
-
fx make:job <Name> Create job (shared/jobs)
|
|
479
|
-
fx make:task <Name> Create task (shared/jobs)
|
|
480
|
-
fx make:ws <Name> --app= Create WsHandler (app-specific)
|
|
481
|
-
fx make:event <Name> Create event handler (shared/events)
|
|
482
|
-
fx make:worker <Name> Create worker (shared/workers)
|
|
483
|
-
fx make:test <Name> Create test
|
|
484
|
-
fx dev Start dev server (--watch)
|
|
485
|
-
fx dev:spa Start dev server + Vite HMR
|
|
486
|
-
fx build:spa Build SPA for production
|
|
487
|
-
fx stop Stop server (graceful)
|
|
488
|
-
fx restart Restart server (graceful)
|
|
489
|
-
fx test Run tests
|
|
490
|
-
fx routes Print route table
|
|
491
|
-
fx config Print fuzionx.yaml
|
|
492
|
-
fx db:sync Sync models → DB (--apply)
|
|
493
|
-
`);
|
|
494
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* CLI — fx 명령어 핸들러
|
|
3
|
+
*
|
|
4
|
+
* fx make:app --type=ssr|spa 앱 디렉토리 생성 (고정 이름)
|
|
5
|
+
* fx make:controller <Name> 컨트롤러 생성
|
|
6
|
+
* fx make:service <Name> 서비스 생성
|
|
7
|
+
* fx make:model <Name> 모델 생성
|
|
8
|
+
* fx make:middleware <Name> 미들웨어 생성
|
|
9
|
+
* fx make:job <Name> Job 생성
|
|
10
|
+
* fx make:task <Name> Task 생성
|
|
11
|
+
* fx make:ws <Name> WsHandler 생성
|
|
12
|
+
* fx make:event <Name> 이벤트 핸들러 생성
|
|
13
|
+
* fx make:worker <Name> Worker 생성
|
|
14
|
+
* fx make:test <Name> 테스트 생성
|
|
15
|
+
* fx dev:spa FuzionX + Vite 동시 실행
|
|
16
|
+
* fx build:spa Vite 프로덕션 빌드
|
|
17
|
+
*
|
|
18
|
+
* @see docs/framework/16-cli.md
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fs } from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { pathToFileURL } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const TPL_DIR = path.join(__dirname, 'templates');
|
|
27
|
+
|
|
28
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
29
|
+
// Template Engine
|
|
30
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 간단 템플릿 치환 — {{varName}} → value
|
|
34
|
+
* @param {string} template
|
|
35
|
+
* @param {object} vars - { Name, name, nameLower, tableName, dbName }
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function render(template, vars) {
|
|
39
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? '');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 템플릿 파일 읽기 + 치환
|
|
44
|
+
* @param {string} relativePath - templates/ 기준 상대 경로
|
|
45
|
+
* @param {object} vars
|
|
46
|
+
* @returns {Promise<string>}
|
|
47
|
+
*/
|
|
48
|
+
async function loadTemplate(relativePath, vars = {}) {
|
|
49
|
+
const content = await fs.readFile(path.join(TPL_DIR, relativePath), 'utf-8');
|
|
50
|
+
return render(content, vars);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
54
|
+
// fx make:* — 코드 생성
|
|
55
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
56
|
+
|
|
57
|
+
/** 앱별 파일 (--app 필수) */
|
|
58
|
+
const APP_TYPE_DIRS = {
|
|
59
|
+
controller: 'controllers',
|
|
60
|
+
service: 'services',
|
|
61
|
+
middleware: 'middleware',
|
|
62
|
+
ws: 'ws',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** 공유 파일 (앱 무관) */
|
|
66
|
+
const SHARED_TYPE_DIRS = {
|
|
67
|
+
model: 'database/models',
|
|
68
|
+
job: 'shared/jobs',
|
|
69
|
+
task: 'shared/jobs',
|
|
70
|
+
event: 'shared/events',
|
|
71
|
+
worker: 'shared/workers',
|
|
72
|
+
test: 'tests',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const TYPE_SUFFIXES = {
|
|
76
|
+
controller: 'Controller',
|
|
77
|
+
service: 'Service',
|
|
78
|
+
model: '',
|
|
79
|
+
middleware: 'Middleware',
|
|
80
|
+
job: 'Job',
|
|
81
|
+
task: '',
|
|
82
|
+
ws: 'Handler',
|
|
83
|
+
event: '',
|
|
84
|
+
worker: '',
|
|
85
|
+
test: '.test',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* fx make:<type> <Name> [--app=<appName>] — 코드 생성
|
|
90
|
+
* @param {string} type - 'controller', 'service', 'model', 등
|
|
91
|
+
* @param {string} name - PascalCase 이름 (e.g. 'User')
|
|
92
|
+
* @param {string} [baseDir='.'] - 프로젝트 루트
|
|
93
|
+
* @param {string} [appName] - 앱 이름 (controller/service/middleware/ws 시 필수)
|
|
94
|
+
* @returns {Promise<string>} - 생성된 파일 경로
|
|
95
|
+
*/
|
|
96
|
+
export async function makeFile(type, name, baseDir = '.', appName) {
|
|
97
|
+
let dir;
|
|
98
|
+
if (APP_TYPE_DIRS[type]) {
|
|
99
|
+
if (!appName) throw new Error(`--app option required for make:${type} (e.g. fx make:${type} ${name} --app=fuzionx)`);
|
|
100
|
+
dir = `app/${appName}/${APP_TYPE_DIRS[type]}`;
|
|
101
|
+
} else if (SHARED_TYPE_DIRS[type]) {
|
|
102
|
+
dir = SHARED_TYPE_DIRS[type];
|
|
103
|
+
} else {
|
|
104
|
+
throw new Error(`Unknown type: ${type}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const suffix = TYPE_SUFFIXES[type];
|
|
108
|
+
const fileName = `${name}${suffix}.js`;
|
|
109
|
+
const filePath = path.join(baseDir, dir, fileName);
|
|
110
|
+
|
|
111
|
+
const vars = {
|
|
112
|
+
Name: name,
|
|
113
|
+
name: name,
|
|
114
|
+
nameLower: name.toLowerCase(),
|
|
115
|
+
tableName: name.toLowerCase() + 's',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const content = await loadTemplate(`make/${type}.js.tpl`, vars);
|
|
119
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
120
|
+
await fs.writeFile(filePath, content);
|
|
121
|
+
|
|
122
|
+
return filePath;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
126
|
+
// 유틸리티
|
|
127
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
128
|
+
|
|
129
|
+
/** 디렉토리 재귀 복사 (템플릿 → 프로젝트) */
|
|
130
|
+
async function copyDirRecursive(src, dst) {
|
|
131
|
+
await fs.mkdir(dst, { recursive: true });
|
|
132
|
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const srcPath = path.join(src, entry.name);
|
|
135
|
+
const dstPath = path.join(dst, entry.name);
|
|
136
|
+
if (entry.isDirectory()) {
|
|
137
|
+
await copyDirRecursive(srcPath, dstPath);
|
|
138
|
+
} else {
|
|
139
|
+
await fs.copyFile(srcPath, dstPath);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
145
|
+
// CLI 엔트리
|
|
146
|
+
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* CLI 엔트리 (fx 명령어)
|
|
150
|
+
* @param {string[]} args - process.argv.slice(2)
|
|
151
|
+
*/
|
|
152
|
+
export async function run(args) {
|
|
153
|
+
const [command, ...rest] = args;
|
|
154
|
+
|
|
155
|
+
// ── fx new → create-fuzionx 안내 ──
|
|
156
|
+
if (command === 'new') {
|
|
157
|
+
console.log(`\n 프로젝트 생성은 create-fuzionx를 사용하세요:\n`);
|
|
158
|
+
console.log(` npx create-fuzionx <name>\n`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (command === 'make:app') {
|
|
163
|
+
const typeFlag = rest.find(a => a.startsWith('--type='));
|
|
164
|
+
const type = typeFlag?.split('=')[1] || rest[0];
|
|
165
|
+
const validTypes = ['ssr', 'spa'];
|
|
166
|
+
|
|
167
|
+
if (!type || !validTypes.includes(type)) {
|
|
168
|
+
console.error(`Usage: fx make:app --type=ssr|spa`);
|
|
169
|
+
console.error(` Available types: ${validTypes.join(', ')}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 앱 이름은 타입에 따라 고정
|
|
174
|
+
const appName = type;
|
|
175
|
+
const appDir = path.join('.', 'app', appName);
|
|
176
|
+
|
|
177
|
+
// 기존 앱 확인
|
|
178
|
+
try {
|
|
179
|
+
await fs.access(appDir);
|
|
180
|
+
console.error(`❌ app/${appName}/ already exists.`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
} catch { /* 없으면 정상 */ }
|
|
183
|
+
|
|
184
|
+
// 빈 디렉토리 생성
|
|
185
|
+
for (const d of ['services', 'middleware', 'ws']) {
|
|
186
|
+
const fullDir = path.join(appDir, d);
|
|
187
|
+
await fs.mkdir(fullDir, { recursive: true });
|
|
188
|
+
await fs.writeFile(path.join(fullDir, '.gitkeep'), '');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 기본 파일 복사 (HomeController, routes, views)
|
|
192
|
+
const appTplDir = path.join(TPL_DIR, 'make/app');
|
|
193
|
+
await copyDirRecursive(appTplDir, appDir);
|
|
194
|
+
|
|
195
|
+
console.log(`✅ Created app/${appName}/ (type: ${type})`);
|
|
196
|
+
console.log(`\n fuzionx.yaml의 apps에 추가하세요:`);
|
|
197
|
+
console.log(` "127.0.0.1:49080": ${appName}\n`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (command?.startsWith('make:')) {
|
|
202
|
+
const type = command.replace('make:', '');
|
|
203
|
+
const name = rest[0];
|
|
204
|
+
if (!name) { console.error(`Usage: fx ${command} <Name> [--app=<appName>]`); process.exit(1); }
|
|
205
|
+
const appFlag = rest.find(a => a.startsWith('--app='));
|
|
206
|
+
const appName = appFlag?.split('=')[1] || undefined;
|
|
207
|
+
const file = await makeFile(type, name, '.', appName);
|
|
208
|
+
console.log(`✅ Created ${file}`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── fx dev — 개발 서버 (Node --watch) ──
|
|
213
|
+
if (command === 'dev') {
|
|
214
|
+
const { execSync } = await import('node:child_process');
|
|
215
|
+
const port = rest.find(a => a.startsWith('--port='))?.split('=')[1] || '';
|
|
216
|
+
const entry = 'app.js';
|
|
217
|
+
const env = port ? `PORT=${port} ` : '';
|
|
218
|
+
console.log(`🚀 Starting dev server...`);
|
|
219
|
+
try {
|
|
220
|
+
execSync(`${env}node --watch ${entry}`, { stdio: 'inherit', cwd: process.cwd() });
|
|
221
|
+
} catch {}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── fx dev:spa — FuzionX + Vite HMR 동시 실행 ──
|
|
226
|
+
if (command === 'dev:spa') {
|
|
227
|
+
const { execSync } = await import('node:child_process');
|
|
228
|
+
const spaDir = path.resolve('app/spa/views/default/spa');
|
|
229
|
+
try {
|
|
230
|
+
await fs.access(spaDir);
|
|
231
|
+
} catch {
|
|
232
|
+
console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
|
|
233
|
+
console.error(' fx make:app --type=spa 로 SPA 앱을 먼저 생성하세요.');
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
console.log(`🚀 Starting dev server + Vite HMR...`);
|
|
237
|
+
try {
|
|
238
|
+
execSync(
|
|
239
|
+
'npx concurrently "node --watch app.js" "cd app/spa/views/default/spa && npx vite"',
|
|
240
|
+
{ stdio: 'inherit', cwd: process.cwd() },
|
|
241
|
+
);
|
|
242
|
+
} catch {}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── fx build:spa — Vite 프로덕션 빌드 ──
|
|
247
|
+
if (command === 'build:spa') {
|
|
248
|
+
const { execSync } = await import('node:child_process');
|
|
249
|
+
const spaDir = path.resolve('app/spa/views/default/spa');
|
|
250
|
+
try {
|
|
251
|
+
await fs.access(spaDir);
|
|
252
|
+
} catch {
|
|
253
|
+
console.error('❌ app/spa/views/default/spa/ 디렉토리가 없습니다.');
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
console.log(`📦 Building SPA for production...`);
|
|
257
|
+
try {
|
|
258
|
+
execSync('npx vite build', { stdio: 'inherit', cwd: spaDir });
|
|
259
|
+
console.log('\n✅ Build complete → public/dist/');
|
|
260
|
+
} catch { process.exit(1); }
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── fx stop — 서버 종료 ──
|
|
265
|
+
if (command === 'stop') {
|
|
266
|
+
const pidFile = path.resolve('fuzionx.pid');
|
|
267
|
+
try {
|
|
268
|
+
const pid = parseInt(await fs.readFile(pidFile, 'utf-8'), 10);
|
|
269
|
+
console.log(`🛑 Stopping PID ${pid}...`);
|
|
270
|
+
process.kill(pid, 'SIGTERM');
|
|
271
|
+
for (let i = 0; i < 50; i++) {
|
|
272
|
+
await new Promise(r => setTimeout(r, 100));
|
|
273
|
+
try { process.kill(pid, 0); } catch { break; }
|
|
274
|
+
}
|
|
275
|
+
await fs.unlink(pidFile).catch(() => {});
|
|
276
|
+
console.log('✅ Server stopped.');
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (err.code === 'ENOENT') {
|
|
279
|
+
console.log('⚠️ fuzionx.pid 없음 — 서버가 실행 중이 아닙니다.');
|
|
280
|
+
} else if (err.code === 'ESRCH') {
|
|
281
|
+
await fs.unlink(pidFile).catch(() => {});
|
|
282
|
+
console.log('⚠️ PID 프로세스가 이미 종료됨. PID 파일 삭제.');
|
|
283
|
+
} else {
|
|
284
|
+
console.error('❌ 종료 실패:', err.message);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── fx restart — 서버 재시작 ──
|
|
291
|
+
if (command === 'restart') {
|
|
292
|
+
const pidFile = path.resolve('fuzionx.pid');
|
|
293
|
+
try {
|
|
294
|
+
const pid = parseInt(await fs.readFile(pidFile, 'utf-8'), 10);
|
|
295
|
+
console.log(`🔄 Stopping PID ${pid}...`);
|
|
296
|
+
process.kill(pid, 'SIGTERM');
|
|
297
|
+
for (let i = 0; i < 50; i++) {
|
|
298
|
+
await new Promise(r => setTimeout(r, 100));
|
|
299
|
+
try { process.kill(pid, 0); } catch { break; }
|
|
300
|
+
}
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (err.code === 'ENOENT') {
|
|
303
|
+
console.log('⚠️ fuzionx.pid 없음 — 새로 시작합니다.');
|
|
304
|
+
} else if (err.code === 'ESRCH') {
|
|
305
|
+
console.log('⚠️ PID 프로세스가 이미 종료됨.');
|
|
306
|
+
} else {
|
|
307
|
+
console.error('❌ 종료 실패:', err.message);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 백그라운드로 재시작
|
|
311
|
+
const { spawn } = await import('node:child_process');
|
|
312
|
+
const logDir = path.resolve('storage/logs');
|
|
313
|
+
await fs.mkdir(logDir, { recursive: true });
|
|
314
|
+
const logFile = path.join(logDir, 'server.log');
|
|
315
|
+
const { openSync } = await import('node:fs');
|
|
316
|
+
const out = openSync(logFile, 'a');
|
|
317
|
+
const child = spawn('node', ['app.js'], {
|
|
318
|
+
cwd: process.cwd(),
|
|
319
|
+
detached: true,
|
|
320
|
+
stdio: ['ignore', out, out],
|
|
321
|
+
});
|
|
322
|
+
child.unref();
|
|
323
|
+
console.log(`🚀 Restarted (PID ${child.pid}) — logs: storage/logs/server.log`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── fx test — 테스트 실행 ──
|
|
328
|
+
if (command === 'test') {
|
|
329
|
+
const { execSync } = await import('node:child_process');
|
|
330
|
+
try {
|
|
331
|
+
execSync('npx vitest run', { stdio: 'inherit', cwd: process.cwd() });
|
|
332
|
+
} catch { process.exit(1); }
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── fx routes — 라우트 테이블 출력 ──
|
|
337
|
+
if (command === 'routes') {
|
|
338
|
+
try {
|
|
339
|
+
const appMod = await import(pathToFileURL(path.resolve('app.js')).href);
|
|
340
|
+
const app = appMod.default || appMod.app;
|
|
341
|
+
if (!app?._appRegistry) { console.error('Cannot load app routes'); return; }
|
|
342
|
+
console.log('');
|
|
343
|
+
console.log(' APP METHOD PATH HANDLER MIDDLEWARE');
|
|
344
|
+
console.log(' ────── ────── ───── ──────── ──────────');
|
|
345
|
+
for (const [appName, appEntry] of app._appRegistry) {
|
|
346
|
+
const routes = appEntry.router.getRoutes();
|
|
347
|
+
for (const r of routes) {
|
|
348
|
+
const aName = appName.padEnd(10);
|
|
349
|
+
const method = r.method.padEnd(8);
|
|
350
|
+
const rPath = r.path.padEnd(22);
|
|
351
|
+
const handler = r.handler?.__handler__
|
|
352
|
+
? `${r.handler.controller?.name || ''}.${r.handler.method}`
|
|
353
|
+
: (typeof r.handler === 'function' ? r.handler.name || 'anonymous' : '');
|
|
354
|
+
const mw = (r.middleware || []).join(', ');
|
|
355
|
+
console.log(` ${aName} ${method} ${rPath} ${handler.padEnd(26)} ${mw}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
console.log('');
|
|
359
|
+
} catch (err) { console.error('Failed to load routes:', err.message); }
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── fx config — fuzionx.yaml 출력 ──
|
|
364
|
+
if (command === 'config') {
|
|
365
|
+
try {
|
|
366
|
+
const configPath = path.resolve('fuzionx.yaml');
|
|
367
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
368
|
+
console.log('\n📄 fuzionx.yaml:\n');
|
|
369
|
+
console.log(content);
|
|
370
|
+
} catch (err) { console.error('Cannot read fuzionx.yaml:', err.message); }
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── fx db:sync — 모델 ↔ DB 스키마 diff ──
|
|
375
|
+
if (command === 'db:sync') {
|
|
376
|
+
try {
|
|
377
|
+
const modelsDir = path.resolve('database/models');
|
|
378
|
+
const files = await fs.readdir(modelsDir);
|
|
379
|
+
const jsFiles = files.filter(f => f.endsWith('.js'));
|
|
380
|
+
const apply = rest.includes('--apply');
|
|
381
|
+
|
|
382
|
+
// fuzionx.yaml에서 DB 경로 읽기
|
|
383
|
+
let dbPath = './storage/database.sqlite';
|
|
384
|
+
try {
|
|
385
|
+
const yaml = await fs.readFile(path.resolve('fuzionx.yaml'), 'utf8');
|
|
386
|
+
const dbMatch = yaml.match(/database:\s*(.+\.sqlite)/);
|
|
387
|
+
if (dbMatch) dbPath = dbMatch[1].trim();
|
|
388
|
+
} catch {}
|
|
389
|
+
|
|
390
|
+
console.log('\n📊 Model Schema Status:\n');
|
|
391
|
+
const models = [];
|
|
392
|
+
|
|
393
|
+
for (const file of jsFiles) {
|
|
394
|
+
const mod = await import(pathToFileURL(path.join(modelsDir, file)).href);
|
|
395
|
+
const Model = mod.default;
|
|
396
|
+
if (!Model) continue;
|
|
397
|
+
const name = path.basename(file, '.js');
|
|
398
|
+
const table = Model.table || name.toLowerCase() + 's';
|
|
399
|
+
const cols = Model.columns || {};
|
|
400
|
+
models.push({ name, table, cols });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (apply) {
|
|
404
|
+
// SQLite DB 열기
|
|
405
|
+
const resolvedDbPath = path.resolve(dbPath);
|
|
406
|
+
await fs.mkdir(path.dirname(resolvedDbPath), { recursive: true });
|
|
407
|
+
const { default: Database } = await import('better-sqlite3');
|
|
408
|
+
const db = new Database(resolvedDbPath);
|
|
409
|
+
|
|
410
|
+
for (const { name, table, cols } of models) {
|
|
411
|
+
const colDefs = [];
|
|
412
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
413
|
+
if (def.type === 'increments') {
|
|
414
|
+
colDefs.push(`\`${col}\` INTEGER PRIMARY KEY AUTOINCREMENT`);
|
|
415
|
+
} else if (def.type === 'integer') {
|
|
416
|
+
colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
|
|
417
|
+
} else if (def.type === 'text') {
|
|
418
|
+
colDefs.push(`\`${col}\` TEXT NOT NULL DEFAULT ''`);
|
|
419
|
+
} else if (def.type === 'datetime') {
|
|
420
|
+
colDefs.push(`\`${col}\` DATETIME DEFAULT CURRENT_TIMESTAMP`);
|
|
421
|
+
} else if (def.type === 'boolean') {
|
|
422
|
+
colDefs.push(`\`${col}\` INTEGER NOT NULL DEFAULT 0`);
|
|
423
|
+
} else {
|
|
424
|
+
// string, etc.
|
|
425
|
+
const unique = def.unique ? ' UNIQUE' : '';
|
|
426
|
+
const dflt = def.default != null ? ` DEFAULT '${def.default}'` : " DEFAULT ''";
|
|
427
|
+
colDefs.push(`\`${col}\` TEXT NOT NULL${dflt}${unique}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
const sql = `CREATE TABLE IF NOT EXISTS \`${table}\` (\n ${colDefs.join(',\n ')}\n)`;
|
|
431
|
+
db.exec(sql);
|
|
432
|
+
|
|
433
|
+
// ── 기존 테이블에 누락된 컬럼 추가 (ALTER TABLE) ──
|
|
434
|
+
const existingCols = db.pragma(`table_info(${table})`).map(c => c.name);
|
|
435
|
+
let addedCols = 0;
|
|
436
|
+
for (const [col, def] of Object.entries(cols)) {
|
|
437
|
+
if (existingCols.includes(col)) continue;
|
|
438
|
+
|
|
439
|
+
let colSql;
|
|
440
|
+
if (def.type === 'integer') {
|
|
441
|
+
colSql = `INTEGER NOT NULL DEFAULT 0`;
|
|
442
|
+
} else if (def.type === 'text') {
|
|
443
|
+
colSql = `TEXT NOT NULL DEFAULT ''`;
|
|
444
|
+
} else if (def.type === 'datetime') {
|
|
445
|
+
colSql = `DATETIME DEFAULT NULL`;
|
|
446
|
+
} else if (def.type === 'boolean') {
|
|
447
|
+
colSql = `INTEGER NOT NULL DEFAULT 0`;
|
|
448
|
+
} else {
|
|
449
|
+
const dflt = def.default != null ? `'${def.default}'` : "''";
|
|
450
|
+
colSql = `TEXT DEFAULT ${dflt}`;
|
|
451
|
+
}
|
|
452
|
+
db.exec(`ALTER TABLE \`${table}\` ADD COLUMN \`${col}\` ${colSql}`);
|
|
453
|
+
addedCols++;
|
|
454
|
+
}
|
|
455
|
+
const status = addedCols > 0 ? `✅ synced (+${addedCols} columns)` : '✅ synced';
|
|
456
|
+
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ${status}`);
|
|
457
|
+
}
|
|
458
|
+
db.close();
|
|
459
|
+
console.log(`\n Database: ${path.resolve(dbPath)}`);
|
|
460
|
+
} else {
|
|
461
|
+
for (const { name, table, cols } of models) {
|
|
462
|
+
console.log(` ${name.padEnd(20)} table: ${table.padEnd(20)} columns: ${Object.keys(cols).length} ⏳ pending`);
|
|
463
|
+
}
|
|
464
|
+
console.log('\n Run with --apply to sync changes to database.');
|
|
465
|
+
}
|
|
466
|
+
console.log('');
|
|
467
|
+
} catch (err) { console.error('db:sync error:', err.message); }
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
console.log(`
|
|
472
|
+
npx create-fuzionx <name> Create new project
|
|
473
|
+
fx make:app --type=ssr|spa Create app (fixed name: app/ssr or app/spa)
|
|
474
|
+
fx make:controller <Name> --app= Create controller (app-specific)
|
|
475
|
+
fx make:service <Name> --app= Create service (app-specific)
|
|
476
|
+
fx make:model <Name> Create model (database/models)
|
|
477
|
+
fx make:middleware <Name> --app= Create middleware (app-specific)
|
|
478
|
+
fx make:job <Name> Create job (shared/jobs)
|
|
479
|
+
fx make:task <Name> Create task (shared/jobs)
|
|
480
|
+
fx make:ws <Name> --app= Create WsHandler (app-specific)
|
|
481
|
+
fx make:event <Name> Create event handler (shared/events)
|
|
482
|
+
fx make:worker <Name> Create worker (shared/workers)
|
|
483
|
+
fx make:test <Name> Create test
|
|
484
|
+
fx dev Start dev server (--watch)
|
|
485
|
+
fx dev:spa Start dev server + Vite HMR
|
|
486
|
+
fx build:spa Build SPA for production
|
|
487
|
+
fx stop Stop server (graceful)
|
|
488
|
+
fx restart Restart server (graceful)
|
|
489
|
+
fx test Run tests
|
|
490
|
+
fx routes Print route table
|
|
491
|
+
fx config Print fuzionx.yaml
|
|
492
|
+
fx db:sync Sync models → DB (--apply)
|
|
493
|
+
`);
|
|
494
|
+
}
|