@fuzionx/framework 0.1.42 → 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.
Files changed (80) hide show
  1. package/README.md +501 -501
  2. package/bin/fx.js +12 -12
  3. package/cli/db-sync.js +100 -100
  4. package/cli/index.js +494 -494
  5. package/cli/templates/make/app/controllers/HomeController.js +14 -14
  6. package/cli/templates/make/app/routes/api.js +7 -7
  7. package/cli/templates/make/app/routes/web.js +5 -5
  8. package/cli/templates/make/app/views/default/errors/404.html +11 -11
  9. package/cli/templates/make/app/views/default/errors/500.html +14 -14
  10. package/cli/templates/make/app/views/default/layouts/main.html +22 -22
  11. package/cli/templates/make/app/views/default/pages/home.html +11 -11
  12. package/cli/templates/make/controller.js.tpl +40 -40
  13. package/cli/templates/make/event.js.tpl +8 -8
  14. package/cli/templates/make/job.js.tpl +10 -10
  15. package/cli/templates/make/middleware.js.tpl +10 -10
  16. package/cli/templates/make/model.js.tpl +15 -15
  17. package/cli/templates/make/service.js.tpl +15 -15
  18. package/cli/templates/make/task.js.tpl +15 -15
  19. package/cli/templates/make/test.js.tpl +7 -7
  20. package/cli/templates/make/worker.js.tpl +14 -14
  21. package/cli/templates/make/ws.js.tpl +18 -18
  22. package/index.js +67 -67
  23. package/lib/core/AppError.js +46 -46
  24. package/lib/core/Application.js +1006 -1006
  25. package/lib/core/AutoLoader.js +227 -227
  26. package/lib/core/Base.js +64 -64
  27. package/lib/core/Config.js +331 -228
  28. package/lib/core/Context.js +484 -484
  29. package/lib/database/ConnectionManager.js +208 -208
  30. package/lib/database/MariaModel.js +29 -29
  31. package/lib/database/Model.js +247 -247
  32. package/lib/database/ModelRegistry.js +72 -72
  33. package/lib/database/MongoModel.js +232 -232
  34. package/lib/database/Pagination.js +37 -37
  35. package/lib/database/PostgreModel.js +29 -29
  36. package/lib/database/QueryBuilder.js +172 -172
  37. package/lib/database/SQLiteModel.js +27 -27
  38. package/lib/database/SqlModel.js +257 -257
  39. package/lib/database/SqlQueryBuilder.js +332 -332
  40. package/lib/helpers/CryptoHelper.js +48 -48
  41. package/lib/helpers/FileHelper.js +61 -61
  42. package/lib/helpers/HashHelper.js +39 -39
  43. package/lib/helpers/I18nHelper.js +174 -174
  44. package/lib/helpers/Logger.js +108 -108
  45. package/lib/helpers/MediaHelper.js +84 -84
  46. package/lib/http/Controller.js +34 -34
  47. package/lib/http/ErrorHandler.js +136 -136
  48. package/lib/http/Middleware.js +43 -43
  49. package/lib/http/Router.js +109 -109
  50. package/lib/http/Validation.js +125 -125
  51. package/lib/middleware/apiAuth.js +79 -79
  52. package/lib/middleware/auth.js +42 -42
  53. package/lib/middleware/bodyParser.js +19 -19
  54. package/lib/middleware/cors.js +47 -47
  55. package/lib/middleware/csrf.js +32 -32
  56. package/lib/middleware/index.js +13 -13
  57. package/lib/middleware/session.js +27 -27
  58. package/lib/middleware/theme.js +20 -20
  59. package/lib/realtime/RoomManager.js +85 -85
  60. package/lib/realtime/WsHandler.js +107 -107
  61. package/lib/schedule/Job.js +38 -38
  62. package/lib/schedule/Queue.js +103 -103
  63. package/lib/schedule/Scheduler.js +171 -171
  64. package/lib/schedule/Task.js +39 -39
  65. package/lib/schedule/WorkerPool.js +225 -225
  66. package/lib/services/EventBus.js +94 -94
  67. package/lib/services/Service.js +261 -261
  68. package/lib/services/Storage.js +112 -112
  69. package/lib/utilities/ArrUtil.js +112 -112
  70. package/lib/utilities/DateUtil.js +98 -98
  71. package/lib/utilities/FunctionUtil.js +119 -119
  72. package/lib/utilities/NumUtil.js +75 -75
  73. package/lib/utilities/ObjectUtil.js +170 -170
  74. package/lib/utilities/PaginationUtil.js +81 -81
  75. package/lib/utilities/StrUtil.js +105 -105
  76. package/lib/utilities/index.js +18 -18
  77. package/lib/view/OpenAPI.js +231 -231
  78. package/lib/view/View.js +83 -83
  79. package/package.json +2 -2
  80. 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
+ }