@restforgejs/platform 5.0.8 → 5.1.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/build-info.json +2 -2
- package/cli/consumer-deploy.js +1 -1
- package/cli/consumer.js +1 -1
- package/generators/cli/fast-track.js +963 -0
- package/generators/cli/payload/sync.js +18 -2
- package/generators/lib/dbschema-kit/connection.js +17 -2
- package/generators/lib/migrate/field-type-resolver.js +18 -5
- package/generators/lib/payload/payload-runner.js +724 -39
- package/generators/lib/templates/dashboard-catalog.js +1 -1
- package/generators/lib/templates/db-connection-env.js +1 -1
- package/generators/lib/templates/dbschema-catalog.js +1 -1
- package/generators/lib/templates/field-validation-catalog.js +1 -1
- package/generators/lib/templates/mysql-template.js +1 -1
- package/generators/lib/templates/oracle-template.js +1 -1
- package/generators/lib/templates/postgres-template.js +1 -1
- package/generators/lib/templates/query-declarative-catalog.js +1 -1
- package/generators/lib/templates/sqlite-template.js +1 -1
- package/generators/lib/utils/database-introspector.js +4 -1
- package/integrity-manifest.json +18 -18
- package/package.json +1 -1
- package/scripts/verify-integrity.js +1 -1
- package/server.js +1 -1
- package/src/components/handlers/adjust_handler.js +1 -1
- package/src/components/handlers/audit_handler.js +1 -1
- package/src/components/handlers/delete_handler.js +1 -1
- package/src/components/handlers/export_handler.js +1 -1
- package/src/components/handlers/import_handler.js +1 -1
- package/src/components/handlers/insert_handler.js +1 -1
- package/src/components/handlers/update_handler.js +1 -1
- package/src/components/handlers/upload_handler.js +1 -1
- package/src/components/handlers/workflow_handler.js +1 -1
- package/src/components/integrations/webhook.js +1 -1
- package/src/consumers/baseConsumer.js +1 -1
- package/src/consumers/declarativeMapper.js +1 -1
- package/src/consumers/handlers/apiHandler.js +1 -1
- package/src/consumers/handlers/consoleHandler.js +1 -1
- package/src/consumers/handlers/databaseHandler.js +1 -1
- package/src/consumers/handlers/index.js +1 -1
- package/src/consumers/handlers/kafkaHandler.js +1 -1
- package/src/consumers/index.js +1 -1
- package/src/consumers/messageTransformer.js +1 -1
- package/src/consumers/validator.js +1 -1
- package/src/core/db/dialect/base-dialect.js +1 -1
- package/src/core/db/dialect/index.js +1 -1
- package/src/core/db/dialect/mysql-dialect.js +1 -1
- package/src/core/db/dialect/oracle-dialect.js +1 -1
- package/src/core/db/dialect/postgres-dialect.js +1 -1
- package/src/core/db/dialect/sqlite-dialect.js +1 -1
- package/src/core/db/flatten-helper.js +1 -1
- package/src/core/db/query-builder-error.js +1 -1
- package/src/core/db/query-builder.js +1 -1
- package/src/core/db/relation-helper.js +1 -1
- package/src/core/handlers/delete_handler.js +1 -1
- package/src/core/handlers/insert_handler.js +1 -1
- package/src/core/handlers/update_handler.js +1 -1
- package/src/core/models/base-model.js +1 -1
- package/src/core/utils/cache-manager.js +1 -1
- package/src/core/utils/component-engine.js +1 -1
- package/src/core/utils/context-builder.js +1 -1
- package/src/core/utils/datetime-formatter.js +1 -1
- package/src/core/utils/datetime-parser.js +1 -1
- package/src/core/utils/db.js +1 -1
- package/src/core/utils/logger.js +1 -1
- package/src/core/utils/payload-loader.js +1 -1
- package/src/core/utils/security-checks.js +1 -1
- package/src/middleware/body-options.js +1 -1
- package/src/middleware/cors.js +1 -1
- package/src/middleware/idempotency.js +1 -1
- package/src/middleware/rate-limiter.js +1 -1
- package/src/middleware/request-logger.js +1 -1
- package/src/middleware/security-headers.js +1 -1
- package/src/models/base-model-mysql.js +1 -1
- package/src/models/base-model-oracle.js +1 -1
- package/src/models/base-model-sqlite.js +1 -1
- package/src/models/base-model.js +1 -1
- package/src/pro/caching/redis-client.js +1 -1
- package/src/pro/caching/redis-helper.js +1 -1
- package/src/pro/consumers/baseConsumer.js +1 -1
- package/src/pro/consumers/declarativeMapper.js +1 -1
- package/src/pro/consumers/handlers/apiHandler.js +1 -1
- package/src/pro/consumers/handlers/consoleHandler.js +1 -1
- package/src/pro/consumers/handlers/databaseHandler.js +1 -1
- package/src/pro/consumers/handlers/index.js +1 -1
- package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
- package/src/pro/consumers/index.js +1 -1
- package/src/pro/consumers/messageTransformer.js +1 -1
- package/src/pro/consumers/validator.js +1 -1
- package/src/pro/database/base-model-mysql.js +1 -1
- package/src/pro/database/base-model-oracle.js +1 -1
- package/src/pro/database/base-model-sqlite.js +1 -1
- package/src/pro/database/db-mysql.js +1 -1
- package/src/pro/database/db-oracle.js +1 -1
- package/src/pro/database/db-sqlite.js +1 -1
- package/src/pro/excel/excel-generator.js +1 -1
- package/src/pro/excel/excel-parser.js +1 -1
- package/src/pro/excel/export-service.js +1 -1
- package/src/pro/excel/export_handler.js +1 -1
- package/src/pro/excel/import-service.js +1 -1
- package/src/pro/excel/import-validator.js +1 -1
- package/src/pro/excel/import_handler.js +1 -1
- package/src/pro/excel/upsert-builder.js +1 -1
- package/src/pro/idgen/idgen-routes.js +1 -1
- package/src/pro/integrations/lookup-resolver.js +1 -1
- package/src/pro/integrations/upload-handler-v2.js +1 -1
- package/src/pro/integrations/upload-handler.js +1 -1
- package/src/pro/integrations/webhook.js +1 -1
- package/src/pro/locking/lock-routes.js +1 -1
- package/src/pro/locking/resource-lock-manager.js +1 -1
- package/src/pro/messaging/kafkaConsumerService.js +1 -1
- package/src/pro/messaging/kafkaService.js +1 -1
- package/src/pro/messaging/messagehubService.js +1 -1
- package/src/pro/messaging/rabbitmqService.js +1 -1
- package/src/pro/scheduler/job-manager.js +1 -1
- package/src/pro/scheduler/job-routes.js +1 -1
- package/src/pro/scheduler/job-validator.js +1 -1
- package/src/pro/storage/base-storage-provider.js +1 -1
- package/src/pro/storage/file-metadata-helper.js +1 -1
- package/src/pro/storage/index.js +1 -1
- package/src/pro/storage/local-storage-provider.js +1 -1
- package/src/pro/storage/s3-storage-provider.js +1 -1
- package/src/pro/storage/upload-cleanup-job.js +1 -1
- package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
- package/src/pro/storage/upload-pending-tracker.js +1 -1
- package/src/pro/websocket/broadcast-helper.js +1 -1
- package/src/pro/websocket/index.js +1 -1
- package/src/pro/websocket/livesync-server.js +1 -1
- package/src/pro/websocket/ws-broadcaster.js +1 -1
- package/src/services/export-service.js +1 -1
- package/src/services/import-service.js +1 -1
- package/src/services/kafkaConsumerService.js +1 -1
- package/src/services/kafkaService.js +1 -1
- package/src/services/messagehubService.js +1 -1
- package/src/services/rabbitmqService.js +1 -1
- package/src/utils/cache-invalidation-registry.js +1 -1
- package/src/utils/cache-manager.js +1 -1
- package/src/utils/component-engine.js +1 -1
- package/src/utils/config-extractor.js +1 -1
- package/src/utils/consumerLogger.js +1 -1
- package/src/utils/context-builder.js +1 -1
- package/src/utils/dashboard-helpers.js +1 -1
- package/src/utils/dateHelper.js +1 -1
- package/src/utils/datetime-formatter.js +1 -1
- package/src/utils/datetime-parser.js +1 -1
- package/src/utils/db-bootstrap.js +1 -1
- package/src/utils/db-mysql.js +1 -1
- package/src/utils/db-oracle.js +1 -1
- package/src/utils/db-sqlite.js +1 -1
- package/src/utils/db.js +1 -1
- package/src/utils/demo-generator.js +1 -1
- package/src/utils/excel-generator.js +1 -1
- package/src/utils/excel-parser.js +1 -1
- package/src/utils/file-watcher.js +1 -1
- package/src/utils/id-generator.js +1 -1
- package/src/utils/idempotency-manager.js +1 -1
- package/src/utils/import-validator.js +1 -1
- package/src/utils/license-client.js +1 -1
- package/src/utils/lock-manager.js +1 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/lookup-resolver.js +1 -1
- package/src/utils/payload-loader.js +1 -1
- package/src/utils/processor-response.js +1 -1
- package/src/utils/rabbitmq.js +1 -1
- package/src/utils/redis-client.js +1 -1
- package/src/utils/redis-helper.js +1 -1
- package/src/utils/request-scope.js +1 -1
- package/src/utils/security-checks.js +1 -1
- package/src/utils/service-resolver.js +1 -1
- package/src/utils/shutdown-coordinator.js +1 -1
- package/src/utils/trusted-keys.js +1 -1
- package/src/utils/upload-handler.js +1 -1
- package/src/utils/upsert-builder.js +1 -1
- package/src/utils/workflow-hook-executor.js +1 -1
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract: fast-track (verb global)
|
|
5
|
+
*
|
|
6
|
+
* Orkestrator alur pembangunan aplikasi RESTForge dari rencana di
|
|
7
|
+
* `docs/plan/fast-track-command.md`. Alur dialog: input konfigurasi (LICENSE +
|
|
8
|
+
* database) -> menu scope -> preview + konfirmasi -> eksekusi.
|
|
9
|
+
*
|
|
10
|
+
* Scope REST API (dan bagian backend pada scope ALL) dieksekusi NYATA dengan
|
|
11
|
+
* mengorkestrasi command restforge existing (urutan mengikuti referensi
|
|
12
|
+
* `restforge-playbook/fast-track.mjs` dan `quick-start.mjs`):
|
|
13
|
+
* ensureEnv -> validate -> config set-default -> schema migrate (folder)
|
|
14
|
+
* -> per tabel: payload generate + endpoint create.
|
|
15
|
+
* Setelah generate, command menawarkan menjalankan runtime server di window
|
|
16
|
+
* CMD baru (Windows).
|
|
17
|
+
*
|
|
18
|
+
* Scope Frontend (dan bagian frontend pada ALL) juga dieksekusi NYATA: payload
|
|
19
|
+
* migrate (RDF -> UDF) per tabel -> restforge-designer activate -> generate.
|
|
20
|
+
*
|
|
21
|
+
* Flag `--sim-no-designer` memaksa cabang dialog "designer NOT FOUND" untuk
|
|
22
|
+
* keperluan uji (mem-bypass probe `restforge-designer --version`).
|
|
23
|
+
*
|
|
24
|
+
* Catatan: contract ini global verb (snapshot key = `fast-track`), file berada
|
|
25
|
+
* di root `generators/cli/` sehingga ter-discover otomatis sejajar dengan `init`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const http = require('node:http');
|
|
31
|
+
const readline = require('node:readline');
|
|
32
|
+
const { spawnSync } = require('node:child_process');
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Default konfigurasi (selaras dengan restforge-playbook/fast-track.mjs)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const DEFAULTS = {
|
|
39
|
+
LICENSE: '8ECD-92A4-86BB-698Q',
|
|
40
|
+
SERVER_ADDRESS: '127.0.0.1',
|
|
41
|
+
SERVER_PORT: '3000',
|
|
42
|
+
WEB_SERVER_PORT: '8000',
|
|
43
|
+
DB_TYPE: 'postgresql',
|
|
44
|
+
DB_HOST: '127.0.0.1',
|
|
45
|
+
DB_PORT: '5432',
|
|
46
|
+
DB_USER: 'postgres',
|
|
47
|
+
DB_PASSWORD: 'postgres1234',
|
|
48
|
+
DB_NAME: 'dbsandbox',
|
|
49
|
+
DB_FILE: './data/sandbox.db'
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const DEFAULT_CONFIG_FILE = 'db-connection.env';
|
|
53
|
+
|
|
54
|
+
// Default DB_PORT/DB_USER/DB_NAME menyesuaikan DB_TYPE yang dipilih; bila tidak
|
|
55
|
+
// terdaftar, fallback ke DEFAULTS.
|
|
56
|
+
const DB_TYPE_DEFAULTS = {
|
|
57
|
+
postgresql: { DB_PORT: '5432', DB_USER: 'postgres' },
|
|
58
|
+
postgres: { DB_PORT: '5432', DB_USER: 'postgres' },
|
|
59
|
+
mysql: { DB_PORT: '3306', DB_USER: 'root' },
|
|
60
|
+
oracle: { DB_PORT: '1521', DB_USER: 'oracle', DB_NAME: 'ORCL' }
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Pilihan scope generate. Frontend selalu menyertakan REST API: payload migrate
|
|
64
|
+
// (RDF -> UDF) butuh file RDF dari pipeline REST API, dan runtime UI fetch ke
|
|
65
|
+
// REST API server. Maka tidak ada opsi "frontend only". Default '2' (REST API +
|
|
66
|
+
// frontend) selaras dengan pola dialog lain yang memakai default saat Enter.
|
|
67
|
+
const SCOPES = {
|
|
68
|
+
'1': { key: 'backend', label: 'REST API only', backend: true, frontend: false },
|
|
69
|
+
'2': { key: 'all', label: 'REST API + Frontend app', backend: true, frontend: true }
|
|
70
|
+
};
|
|
71
|
+
const DEFAULT_SCOPE = '2';
|
|
72
|
+
|
|
73
|
+
// Map DB_TYPE (nilai pada db-connection.env) ke token flag `--database` pada
|
|
74
|
+
// endpoint create. Env memakai 'postgresql', CLI mengharapkan 'postgres'.
|
|
75
|
+
const DB_FLAG = {
|
|
76
|
+
postgresql: 'postgres',
|
|
77
|
+
postgres: 'postgres',
|
|
78
|
+
mysql: 'mysql',
|
|
79
|
+
oracle: 'oracle',
|
|
80
|
+
sqlite: 'sqlite'
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Helper output
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const LEADER_WIDTH = 36;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Membangun baris ber-leader titik: `label .......... value`.
|
|
91
|
+
*/
|
|
92
|
+
function leader(label, value, width = LEADER_WIDTH, fill = '.') {
|
|
93
|
+
let line = `${label} `;
|
|
94
|
+
while (line.length < width) line += fill;
|
|
95
|
+
return `${line} ${value}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rule(char = '=', width = 64) {
|
|
99
|
+
return char.repeat(width);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Membuat satu prompter berbasis queue baris untuk dipakai sepanjang command.
|
|
104
|
+
*
|
|
105
|
+
* Memakai `rl.question` berturut-turut pada input yang di-pipe tidak reliable:
|
|
106
|
+
* baris yang datang pada celah antar-prompt ter-emit tanpa handler dan hilang,
|
|
107
|
+
* lalu EOF membuat prompt berikutnya menggantung. Pendekatan ini menampung
|
|
108
|
+
* setiap event `line` ke dalam queue sehingga tidak ada input yang hilang, dan
|
|
109
|
+
* mengembalikan string kosong saat EOF (input habis).
|
|
110
|
+
*/
|
|
111
|
+
function createPrompter() {
|
|
112
|
+
const rl = readline.createInterface({
|
|
113
|
+
input: process.stdin,
|
|
114
|
+
output: process.stdout
|
|
115
|
+
});
|
|
116
|
+
const lineQueue = [];
|
|
117
|
+
const waiters = [];
|
|
118
|
+
let closed = false;
|
|
119
|
+
|
|
120
|
+
rl.on('line', (line) => {
|
|
121
|
+
if (waiters.length > 0) waiters.shift()(line);
|
|
122
|
+
else lineQueue.push(line);
|
|
123
|
+
});
|
|
124
|
+
rl.on('close', () => {
|
|
125
|
+
closed = true;
|
|
126
|
+
while (waiters.length > 0) waiters.shift()('');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const ask = (message) => {
|
|
130
|
+
return new Promise((resolve) => {
|
|
131
|
+
// Saat input di-pipe (bukan TTY), readline tidak meng-echo newline,
|
|
132
|
+
// sehingga prompt berurutan menumpuk di satu baris. Echo newline
|
|
133
|
+
// manual agar transcript non-interaktif tetap terbaca.
|
|
134
|
+
const finish = (val) => {
|
|
135
|
+
if (!process.stdin.isTTY) process.stdout.write('\n');
|
|
136
|
+
resolve(val);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
// Input sudah ter-buffer (umumnya mode pipe): tampilkan label prompt
|
|
140
|
+
// langsung lalu konsumsi dari queue, tanpa line-editor.
|
|
141
|
+
if (lineQueue.length > 0) {
|
|
142
|
+
process.stdout.write(message);
|
|
143
|
+
finish(lineQueue.shift());
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (closed) {
|
|
147
|
+
process.stdout.write(message);
|
|
148
|
+
finish('');
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Mode interaktif: serahkan rendering prompt ke readline via
|
|
153
|
+
// setPrompt/prompt agar readline mengetahui lebar prompt dan
|
|
154
|
+
// line-editing (backspace, panah, Ctrl+U) berfungsi benar. Menulis
|
|
155
|
+
// prompt manual membuat readline me-refresh baris dari kolom 0 dengan
|
|
156
|
+
// prompt default ("> ") sehingga teks prompt asli terhapus saat backspace.
|
|
157
|
+
rl.setPrompt(message);
|
|
158
|
+
rl.prompt();
|
|
159
|
+
waiters.push(finish);
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return { ask, close: () => rl.close() };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Error yang menghentikan command tanpa pesan "Error: ..." tambahan dari
|
|
168
|
+
* cli-entry (dialog sudah mencetak konteksnya sendiri).
|
|
169
|
+
*/
|
|
170
|
+
function stop(message) {
|
|
171
|
+
const err = new Error(message || 'fast-track stopped');
|
|
172
|
+
err.silent = true;
|
|
173
|
+
return err;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Masking license untuk tampilan preview: grup pertama dan terakhir terlihat,
|
|
178
|
+
* grup tengah disamarkan (mis. `8ECD-****-****-698Q`). Hanya untuk tampilan —
|
|
179
|
+
* nilai asli tetap dipakai untuk penulisan env dan command.
|
|
180
|
+
*/
|
|
181
|
+
function maskLicense(license) {
|
|
182
|
+
if (!license || typeof license !== 'string') return license;
|
|
183
|
+
const groups = license.split('-');
|
|
184
|
+
if (groups.length >= 3) {
|
|
185
|
+
return groups
|
|
186
|
+
.map((g, i) => (i === 0 || i === groups.length - 1) ? g : '*'.repeat(g.length))
|
|
187
|
+
.join('-');
|
|
188
|
+
}
|
|
189
|
+
if (license.length > 8) {
|
|
190
|
+
return `${license.slice(0, 4)}${'*'.repeat(license.length - 8)}${license.slice(-4)}`;
|
|
191
|
+
}
|
|
192
|
+
return license;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Representasi database untuk baris preview, berdasarkan konfigurasi input.
|
|
197
|
+
*/
|
|
198
|
+
function describeDatabase(cfg) {
|
|
199
|
+
if (cfg.DB_TYPE === 'sqlite') return `sqlite @ ${cfg.DB_FILE}`;
|
|
200
|
+
return `${cfg.DB_TYPE} @ ${cfg.DB_HOST}:${cfg.DB_PORT}/${cfg.DB_NAME}`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Pengumpulan data preview (read-only, tanpa efek samping)
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
const FALLBACK_TABLES = [
|
|
208
|
+
'customer', 'supplier', 'warehouse', 'item-project',
|
|
209
|
+
'stock-inbound', 'stock-inbound-item'
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Mengumpulkan nama tabel dari folder schema bila ada; bila tidak, memakai
|
|
214
|
+
* daftar fallback yang dipadkan sampai 30 entri agar contoh kanonik (30 tabel)
|
|
215
|
+
* tetap dapat dirender.
|
|
216
|
+
*/
|
|
217
|
+
function collectTables(schemaDir) {
|
|
218
|
+
let names = [];
|
|
219
|
+
let realSchema = false;
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
if (schemaDir && fs.existsSync(schemaDir) && fs.statSync(schemaDir).isDirectory()) {
|
|
223
|
+
names = fs.readdirSync(schemaDir)
|
|
224
|
+
.filter((f) => f.endsWith('.js') && !f.startsWith('_') && !f.endsWith('.test.js'))
|
|
225
|
+
.map((f) => f.slice(0, -3))
|
|
226
|
+
.sort();
|
|
227
|
+
if (names.length > 0) realSchema = true;
|
|
228
|
+
}
|
|
229
|
+
} catch (_err) {
|
|
230
|
+
names = [];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!realSchema) {
|
|
234
|
+
names = FALLBACK_TABLES.slice();
|
|
235
|
+
let n = names.length + 1;
|
|
236
|
+
while (names.length < 30) {
|
|
237
|
+
names.push(`entity-${String(n).padStart(2, '0')}`);
|
|
238
|
+
n += 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { names, realSchema };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Preflight: cek dependency restforge-designer (sebelum input konfigurasi)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
function checkDesigner(args) {
|
|
250
|
+
console.log('');
|
|
251
|
+
console.log('[Preflight]');
|
|
252
|
+
|
|
253
|
+
// Probe nyata: jalankan `restforge-designer --version`. Flag --sim-no-designer
|
|
254
|
+
// memaksa cabang NOT FOUND untuk keperluan uji dialog.
|
|
255
|
+
let found = false;
|
|
256
|
+
let version = '';
|
|
257
|
+
if (!args['sim-no-designer']) {
|
|
258
|
+
const r = spawnSync('cmd', ['/S', '/C', 'restforge-designer --version'], { encoding: 'utf8' });
|
|
259
|
+
found = !r.error && r.status === 0;
|
|
260
|
+
if (found && r.stdout) version = r.stdout.trim().split(/\r?\n/)[0];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (!found) {
|
|
264
|
+
console.log(leader(' > restforge-designer', 'NOT FOUND', 32));
|
|
265
|
+
console.log('');
|
|
266
|
+
console.log(` ${rule('=', 60)}`);
|
|
267
|
+
console.log(' ERROR: restforge-designer is required but not installed.');
|
|
268
|
+
console.log(` ${rule('=', 60)}`);
|
|
269
|
+
console.log(' fast-track generates the frontend app using restforge-designer.');
|
|
270
|
+
console.log(' Download and install it from:');
|
|
271
|
+
console.log('');
|
|
272
|
+
console.log(' https://restforge.dev/download.html');
|
|
273
|
+
console.log('');
|
|
274
|
+
console.log(' Then re-run this command.');
|
|
275
|
+
console.log('');
|
|
276
|
+
throw stop('restforge-designer not installed');
|
|
277
|
+
}
|
|
278
|
+
console.log(leader(' > restforge-designer', `${version || 'installed'} OK`, 32));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// Fase input konfigurasi (LICENSE + database), mengikuti fast-track.mjs
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
async function collectConfig(args, ask) {
|
|
286
|
+
console.log('');
|
|
287
|
+
console.log(rule('='));
|
|
288
|
+
console.log(' RESTForge Fast-Track — Configuration');
|
|
289
|
+
console.log(rule('='));
|
|
290
|
+
console.log('');
|
|
291
|
+
console.log(' Enter configuration (press Enter to use the default value):');
|
|
292
|
+
console.log('');
|
|
293
|
+
|
|
294
|
+
const askField = async (label, def) => {
|
|
295
|
+
const input = (await ask(` ${label} (${def}): `)).trim();
|
|
296
|
+
return input || def;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const cfg = {};
|
|
300
|
+
|
|
301
|
+
// LICENSE: bila --license diberikan, dipakai sebagai default prompt.
|
|
302
|
+
const licenseDefault = args.license || DEFAULTS.LICENSE;
|
|
303
|
+
cfg.LICENSE = await askField('LICENSE', licenseDefault);
|
|
304
|
+
|
|
305
|
+
// Alamat & port. Label user-facing REST_API_PORT, namun TETAP disimpan dan
|
|
306
|
+
// ditulis ke db-connection.env sebagai SERVER_PORT (key yang dikonsumsi
|
|
307
|
+
// `restforge serve`). WEB_SERVER_PORT adalah port frontend (`npx serve`),
|
|
308
|
+
// dialirkan via `payload migrate --port` — tidak masuk runtime config.
|
|
309
|
+
cfg.SERVER_ADDRESS = await askField('SERVER_ADDRESS', DEFAULTS.SERVER_ADDRESS);
|
|
310
|
+
cfg.SERVER_PORT = await askField('REST_API_PORT', DEFAULTS.SERVER_PORT);
|
|
311
|
+
cfg.WEB_SERVER_PORT = await askField('WEB_SERVER_PORT', DEFAULTS.WEB_SERVER_PORT);
|
|
312
|
+
|
|
313
|
+
// DB_TYPE menentukan atribut yang diminta berikutnya.
|
|
314
|
+
console.log('');
|
|
315
|
+
console.log(' Available DB_TYPE: postgresql, mysql, oracle, sqlite');
|
|
316
|
+
cfg.DB_TYPE = (await askField('DB_TYPE', DEFAULTS.DB_TYPE)).toLowerCase();
|
|
317
|
+
|
|
318
|
+
if (cfg.DB_TYPE === 'sqlite') {
|
|
319
|
+
console.log('');
|
|
320
|
+
console.log(' SQLite mode: DB_HOST, DB_PORT, DB_USER, DB_PASSWORD are ignored.');
|
|
321
|
+
console.log(' The database file path is set in DB_FILE.');
|
|
322
|
+
console.log('');
|
|
323
|
+
cfg.DB_FILE = await askField('DB_FILE (.db file path)', DEFAULTS.DB_FILE);
|
|
324
|
+
cfg.DB_NAME = cfg.DB_FILE;
|
|
325
|
+
} else {
|
|
326
|
+
const dbDef = DB_TYPE_DEFAULTS[cfg.DB_TYPE] || {};
|
|
327
|
+
cfg.DB_HOST = await askField('DB_HOST', DEFAULTS.DB_HOST);
|
|
328
|
+
cfg.DB_PORT = await askField('DB_PORT', dbDef.DB_PORT || DEFAULTS.DB_PORT);
|
|
329
|
+
cfg.DB_USER = await askField('DB_USER', dbDef.DB_USER || DEFAULTS.DB_USER);
|
|
330
|
+
cfg.DB_PASSWORD = await askField('DB_PASSWORD', DEFAULTS.DB_PASSWORD);
|
|
331
|
+
cfg.DB_NAME = await askField('DB_NAME', dbDef.DB_NAME || DEFAULTS.DB_NAME);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return cfg;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Fase pemilihan scope generate (menu)
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
async function selectScope(ask) {
|
|
342
|
+
console.log('');
|
|
343
|
+
console.log(' Select what to generate:');
|
|
344
|
+
console.log('');
|
|
345
|
+
console.log(' 1. Generate REST API Only');
|
|
346
|
+
console.log(' 2. Generate REST API with Frontend Application');
|
|
347
|
+
console.log('');
|
|
348
|
+
|
|
349
|
+
let scope = null;
|
|
350
|
+
while (!scope) {
|
|
351
|
+
const choice = (await ask(` Choice (1-2) [${DEFAULT_SCOPE}]: `)).trim() || DEFAULT_SCOPE;
|
|
352
|
+
scope = SCOPES[choice];
|
|
353
|
+
if (!scope) console.log(' Invalid choice. Enter 1 or 2.');
|
|
354
|
+
}
|
|
355
|
+
return scope;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Fase preview + konfirmasi
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
async function confirmDefaultMode(ctx, ask) {
|
|
363
|
+
console.log('');
|
|
364
|
+
console.log(rule('='));
|
|
365
|
+
console.log(' RESTForge Fast-Track');
|
|
366
|
+
console.log(rule('='));
|
|
367
|
+
console.log('');
|
|
368
|
+
console.log(` Project : ${ctx.project}`);
|
|
369
|
+
console.log(` Generate : ${ctx.scope.label}`);
|
|
370
|
+
console.log(` Schema : ${ctx.schemaFlag} (${ctx.tables.length} SDF files)`);
|
|
371
|
+
console.log(` Config : ${ctx.configFlag} (will be written)`);
|
|
372
|
+
console.log(` License : ${maskLicense(ctx.cfg.LICENSE)}`);
|
|
373
|
+
console.log(` REST API : ${ctx.cfg.SERVER_ADDRESS}:${ctx.cfg.SERVER_PORT} (SERVER_PORT)`);
|
|
374
|
+
console.log(` Web server : ${ctx.cfg.SERVER_ADDRESS}:${ctx.cfg.WEB_SERVER_PORT}`);
|
|
375
|
+
console.log(` Database : ${describeDatabase(ctx.cfg)}`);
|
|
376
|
+
console.log(' Mode : sync (use --overwrite to drop & regenerate)');
|
|
377
|
+
console.log('');
|
|
378
|
+
const answer = (await ask(' Continue? (Y/n): ')).trim().toLowerCase();
|
|
379
|
+
console.log('');
|
|
380
|
+
if (answer === 'n' || answer === 'no') {
|
|
381
|
+
console.log(' Aborted. No changes were made.');
|
|
382
|
+
throw stop('user aborted');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function confirmOverwriteMode(ctx, ask) {
|
|
387
|
+
console.log('');
|
|
388
|
+
console.log(rule('='));
|
|
389
|
+
console.log(' RESTForge Fast-Track — --overwrite MODE (DESTRUCTIVE)');
|
|
390
|
+
console.log(rule('='));
|
|
391
|
+
console.log('');
|
|
392
|
+
console.log(` Project : ${ctx.project}`);
|
|
393
|
+
console.log(` Generate : ${ctx.scope.label}`);
|
|
394
|
+
console.log(` License : ${maskLicense(ctx.cfg.LICENSE)}`);
|
|
395
|
+
console.log(` Database : ${describeDatabase(ctx.cfg)}`);
|
|
396
|
+
console.log('');
|
|
397
|
+
console.log(' The following actions will run:');
|
|
398
|
+
if (ctx.scope.backend) {
|
|
399
|
+
console.log(` • DROP TABLE : ${ctx.tables.length} tables (DATA WILL BE LOST)`);
|
|
400
|
+
}
|
|
401
|
+
const overwriteTargets = [
|
|
402
|
+
ctx.scope.backend && 'RDF, endpoint',
|
|
403
|
+
ctx.scope.frontend && 'frontend'
|
|
404
|
+
].filter(Boolean).join(', ');
|
|
405
|
+
console.log(` • Overwrite : affected ${overwriteTargets} files`);
|
|
406
|
+
console.log(' • Archive : old files backed up before overwrite');
|
|
407
|
+
console.log(' (e.g. payload/visitors.json.archive.001)');
|
|
408
|
+
console.log('');
|
|
409
|
+
console.log(' This action cannot be undone.');
|
|
410
|
+
const answer = (await ask(' Continue? (y/N): ')).trim().toLowerCase();
|
|
411
|
+
console.log('');
|
|
412
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
413
|
+
console.log(' Aborted. No changes were made.');
|
|
414
|
+
throw stop('user aborted');
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Eksekusi nyata (REST API) — orkestrasi command restforge existing
|
|
420
|
+
//
|
|
421
|
+
// Daftar command mengikuti referensi yang sudah terbukti di
|
|
422
|
+
// restforge-playbook/fast-track.mjs dan quick-start.mjs:
|
|
423
|
+
// init -> isi env -> validate -> config set-default -> schema migrate (folder)
|
|
424
|
+
// -> per tabel: payload generate + endpoint create -> (opsional) serve.
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
function phase(title) {
|
|
428
|
+
console.log('');
|
|
429
|
+
console.log(rule('='));
|
|
430
|
+
console.log(` ${title}`);
|
|
431
|
+
console.log(rule('='));
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Jalankan command CMD inline; hentikan pipeline bila gagal. */
|
|
435
|
+
function run(cmd, cwd, { allowNonZero = false } = {}) {
|
|
436
|
+
console.log(`\n#${cmd}`);
|
|
437
|
+
const r = spawnSync('cmd', ['/S', '/C', cmd], { cwd, stdio: 'inherit' });
|
|
438
|
+
if (allowNonZero) {
|
|
439
|
+
if (r.error) console.log(`\n [WARN] ${cmd}\n ${r.error.message}`);
|
|
440
|
+
return r.status;
|
|
441
|
+
}
|
|
442
|
+
if (r.error) {
|
|
443
|
+
console.log(`\n [FAILED] ${cmd}\n ${r.error.message}`);
|
|
444
|
+
throw stop(`command failed to start: ${cmd}`);
|
|
445
|
+
}
|
|
446
|
+
if (r.status !== 0) {
|
|
447
|
+
console.log(`\n [FAILED] exit code ${r.status}: ${cmd}`);
|
|
448
|
+
throw stop(`command exited with code ${r.status}`);
|
|
449
|
+
}
|
|
450
|
+
return r.status;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Perbarui VALUE sejumlah KEY pada file .env tanpa mengubah baris lain. */
|
|
454
|
+
function updateEnvFile(filePath, values) {
|
|
455
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
456
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
457
|
+
const lines = content.split(/\r?\n/);
|
|
458
|
+
const remaining = new Set(Object.keys(values));
|
|
459
|
+
for (let i = 0; i < lines.length; i++) {
|
|
460
|
+
const m = lines[i].match(/^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
461
|
+
if (m && remaining.has(m[2])) {
|
|
462
|
+
lines[i] = `${m[1]}${m[2]}=${values[m[2]]}`;
|
|
463
|
+
remaining.delete(m[2]);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
for (const k of remaining) lines.push(`${k}=${values[k]}`);
|
|
467
|
+
fs.writeFileSync(filePath, lines.join(eol));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Susun nilai env yang akan ditulis dari konfigurasi input. */
|
|
471
|
+
function envValuesFromCfg(cfg) {
|
|
472
|
+
const v = {
|
|
473
|
+
LICENSE: cfg.LICENSE,
|
|
474
|
+
SERVER_ADDRESS: cfg.SERVER_ADDRESS,
|
|
475
|
+
SERVER_PORT: cfg.SERVER_PORT,
|
|
476
|
+
DB_TYPE: cfg.DB_TYPE
|
|
477
|
+
};
|
|
478
|
+
if (cfg.DB_TYPE === 'sqlite') {
|
|
479
|
+
v.DB_FILE = cfg.DB_FILE;
|
|
480
|
+
v.DB_NAME = cfg.DB_NAME;
|
|
481
|
+
} else {
|
|
482
|
+
v.DB_HOST = cfg.DB_HOST;
|
|
483
|
+
v.DB_PORT = cfg.DB_PORT;
|
|
484
|
+
v.DB_USER = cfg.DB_USER;
|
|
485
|
+
v.DB_PASSWORD = cfg.DB_PASSWORD;
|
|
486
|
+
v.DB_NAME = cfg.DB_NAME;
|
|
487
|
+
}
|
|
488
|
+
return v;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Pastikan config/<file> ada (init bila belum) lalu isi nilai dari input. */
|
|
492
|
+
function ensureEnv(ctx) {
|
|
493
|
+
const configPath = path.join(ctx.cwd, 'config', ctx.configFlag);
|
|
494
|
+
if (!fs.existsSync(configPath)) {
|
|
495
|
+
run('npx restforge init --force', ctx.cwd);
|
|
496
|
+
}
|
|
497
|
+
if (!fs.existsSync(configPath)) {
|
|
498
|
+
throw stop(`config file not found after init: ${configPath}`);
|
|
499
|
+
}
|
|
500
|
+
updateEnvFile(configPath, envValuesFromCfg(ctx.cfg));
|
|
501
|
+
console.log(` ~ config/${ctx.configFlag} written from input.`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const AUDIT_COLS = new Set(['created_at', 'created_by', 'updated_at', 'updated_by']);
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Parse satu file SDF menjadi descriptor model. Deterministik dari SDF (tanpa DB):
|
|
508
|
+
* { kebab, table, pk, fields[{name,isPk,isAudit,fk}], fks[{childCol,parentTable,parentCol}], displayCols }
|
|
509
|
+
* displayCols = kolom yang mengandung `code`/`name`, di luar PK & kolom audit
|
|
510
|
+
* (dipakai sebagai kolom display saat tabel ini menjadi parent FK).
|
|
511
|
+
*/
|
|
512
|
+
function parseModel(absFile, kebab) {
|
|
513
|
+
let content = '';
|
|
514
|
+
try { content = fs.readFileSync(absFile, 'utf8'); } catch (_err) { /* kosong */ }
|
|
515
|
+
const table = (content.match(/defineModel\(\s*['"]([^'"]+)['"]/) || [, kebab.replace(/-/g, '_')])[1];
|
|
516
|
+
const block = (content.match(/fields\s*:\s*\{([\s\S]*?)\}/) || [, ''])[1];
|
|
517
|
+
|
|
518
|
+
const fields = [];
|
|
519
|
+
const re = /(\w+)\s*:\s*'([^']*)'/g;
|
|
520
|
+
let m;
|
|
521
|
+
while ((m = re.exec(block)) !== null) {
|
|
522
|
+
const name = m[1];
|
|
523
|
+
const spec = m[2];
|
|
524
|
+
const fkMatch = spec.match(/fk:(\w+)\.(\w+)/);
|
|
525
|
+
fields.push({
|
|
526
|
+
name,
|
|
527
|
+
isPk: /\bpk\b/.test(spec),
|
|
528
|
+
isAudit: AUDIT_COLS.has(name),
|
|
529
|
+
fk: fkMatch ? { parentTable: fkMatch[1], parentCol: fkMatch[2] } : null
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
const pk = (fields.find((f) => f.isPk) || {}).name || null;
|
|
533
|
+
const displayCols = fields
|
|
534
|
+
.filter((f) => !f.isPk && !f.isAudit && /(code|name)/i.test(f.name))
|
|
535
|
+
.map((f) => f.name);
|
|
536
|
+
const fks = fields
|
|
537
|
+
.filter((f) => f.fk)
|
|
538
|
+
.map((f) => ({ childCol: f.name, parentTable: f.fk.parentTable, parentCol: f.fk.parentCol }));
|
|
539
|
+
|
|
540
|
+
return { kebab, table, pk, fields, fks, displayCols };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Bangun daftar model dari folder schema. */
|
|
544
|
+
function buildTableEntries(schemaDir) {
|
|
545
|
+
const files = fs.readdirSync(schemaDir)
|
|
546
|
+
.filter((f) => f.endsWith('.js') && !f.startsWith('_') && !f.endsWith('.test.js'))
|
|
547
|
+
.sort();
|
|
548
|
+
return files.map((f) => parseModel(path.join(schemaDir, f), f.slice(0, -3)));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Turunkan daftar `--fk-columns` untuk `payload sync --expand-fk` dari SDF:
|
|
553
|
+
* `<parentTable>.<displayCol>` untuk tiap kolom display (code/name) tiap parent.
|
|
554
|
+
* Kosong bila tidak ada kolom display terdeteksi (jatuh ke mode AUTO command).
|
|
555
|
+
*/
|
|
556
|
+
function fkColumnsForEntry(entry, byTable) {
|
|
557
|
+
const cols = [];
|
|
558
|
+
for (const fk of entry.fks) {
|
|
559
|
+
const parent = byTable.get(fk.parentTable);
|
|
560
|
+
const disp = parent ? parent.displayCols : [];
|
|
561
|
+
for (const dc of disp) cols.push(`${fk.parentTable}.${dc}`);
|
|
562
|
+
}
|
|
563
|
+
return cols;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Jeda async sederhana. */
|
|
567
|
+
function sleep(ms) {
|
|
568
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Satu kali GET <url>; resolve true bila HTTP 200 dan (jika JSON) status:'ok'. */
|
|
572
|
+
function pingOnce(url, perReqTimeoutMs) {
|
|
573
|
+
return new Promise((resolve) => {
|
|
574
|
+
const req = http.get(url, (res) => {
|
|
575
|
+
let body = '';
|
|
576
|
+
res.on('data', (c) => { body += c; });
|
|
577
|
+
res.on('end', () => {
|
|
578
|
+
let ok = res.statusCode === 200;
|
|
579
|
+
if (ok && body) {
|
|
580
|
+
// Bila body JSON, pastikan status:'ok'. Bila bukan JSON, cukup andalkan 200.
|
|
581
|
+
try { ok = JSON.parse(body).status === 'ok'; } catch { /* keep 200 */ }
|
|
582
|
+
}
|
|
583
|
+
resolve(ok);
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
req.setTimeout(perReqTimeoutMs, () => { req.destroy(); resolve(false); });
|
|
587
|
+
req.on('error', () => resolve(false));
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Poll <url> sampai sehat atau timeout. Dipakai setelah serve di-spawn. */
|
|
592
|
+
async function waitForHealth(url, { timeoutMs = 30000, intervalMs = 600 } = {}) {
|
|
593
|
+
const start = Date.now();
|
|
594
|
+
while (Date.now() - start < timeoutMs) {
|
|
595
|
+
if (await pingOnce(url, 2000)) return { ok: true, elapsedMs: Date.now() - start };
|
|
596
|
+
await sleep(intervalMs);
|
|
597
|
+
}
|
|
598
|
+
return { ok: false, elapsedMs: Date.now() - start };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Host untuk health URL: 0.0.0.0/kosong -> localhost (mirror banner runtime). */
|
|
602
|
+
function healthHost(serverAddress) {
|
|
603
|
+
return (!serverAddress || serverAddress === '0.0.0.0') ? 'localhost' : serverAddress;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Bila port dipakai, hentikan proses lama (Windows) sebelum serve. */
|
|
607
|
+
function freePort(port) {
|
|
608
|
+
const res = spawnSync('cmd', ['/S', '/C', `netstat -ano | findstr :${port}`], { encoding: 'utf8' });
|
|
609
|
+
const pids = new Set();
|
|
610
|
+
for (const line of (res.stdout || '').split(/\r?\n/)) {
|
|
611
|
+
const p = line.trim().split(/\s+/);
|
|
612
|
+
if (p.length >= 5 && /LISTENING/i.test(p[3]) && p[1].endsWith(`:${port}`)) {
|
|
613
|
+
if (/^\d+$/.test(p[4]) && p[4] !== '0') pids.add(p[4]);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (pids.size === 0) return;
|
|
617
|
+
console.log(` Port ${port} in use (PID ${[...pids].join(', ')}); stopping old process...`);
|
|
618
|
+
for (const pid of pids) spawnSync('cmd', ['/S', '/C', `taskkill /PID ${pid} /F`], { stdio: 'inherit' });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Pipeline REST API nyata: env -> validate -> migrate -> payload+endpoint. */
|
|
622
|
+
function runBackendPipeline(ctx) {
|
|
623
|
+
const dbFlag = DB_FLAG[ctx.cfg.DB_TYPE] || ctx.cfg.DB_TYPE;
|
|
624
|
+
const cfgArg = `--config=${ctx.configFlag}`;
|
|
625
|
+
|
|
626
|
+
phase('[1/4] Config, license & database validation');
|
|
627
|
+
ensureEnv(ctx);
|
|
628
|
+
run(`npx restforge validate ${cfgArg} --auto-create-db`, ctx.cwd);
|
|
629
|
+
run(`npx restforge config set-default ${cfgArg}`, ctx.cwd);
|
|
630
|
+
|
|
631
|
+
phase('[2/4] Schema migrate (SDF -> database)');
|
|
632
|
+
// Mode --overwrite: drop tabel lalu re-create (destruktif). Tanpa --overwrite:
|
|
633
|
+
// CREATE TABLE IF NOT EXISTS (tabel existing dilewati).
|
|
634
|
+
const dropFlag = ctx.overwrite ? ' --drop=true' : '';
|
|
635
|
+
run(`npx restforge schema migrate --path=${ctx.schemaFlag} ${cfgArg} --auto-create-db${dropFlag}`, ctx.cwd);
|
|
636
|
+
|
|
637
|
+
phase('[3/4] Payload RDF (generate)');
|
|
638
|
+
for (const t of ctx.tableEntries) {
|
|
639
|
+
run(`npx restforge payload generate --table=${t.table} ${cfgArg}`, ctx.cwd);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
phase('[3b/4] FK expansion (payload sync --expand-fk)');
|
|
643
|
+
const fkTables = ctx.tableEntries.filter((t) => t.fks.length > 0);
|
|
644
|
+
if (fkTables.length === 0) {
|
|
645
|
+
console.log(' No FK to expand (RDF stays flat).');
|
|
646
|
+
} else {
|
|
647
|
+
for (const t of fkTables) {
|
|
648
|
+
const cols = fkColumnsForEntry(t, ctx.byTable);
|
|
649
|
+
const colFlag = cols.length ? ` --fk-columns=${cols.join(',')}` : '';
|
|
650
|
+
run(`npx restforge payload sync --table=${t.table} --expand-fk${colFlag}`, ctx.cwd);
|
|
651
|
+
}
|
|
652
|
+
// Catatan: fieldNameLookup pada parent TIDAK disuntik. Frontend default
|
|
653
|
+
// memakai kolom `name` untuk display dropdown; fieldNameLookup hanya untuk
|
|
654
|
+
// custom display dan diserahkan ke user (atau command khusus).
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
phase('[4/4] REST endpoints');
|
|
658
|
+
for (const t of ctx.tableEntries) {
|
|
659
|
+
run(`npx restforge endpoint create --project=${ctx.project} --name=${t.kebab} --payload=${t.kebab}.json ${cfgArg} --database=${dbFlag} --force`, ctx.cwd);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Pipeline frontend nyata: migrate RDF->UDF per tabel (agregator di-akumulasi),
|
|
665
|
+
* lalu designer activate + generate. Urutan mengikuti fast-track.mjs/quick-start.
|
|
666
|
+
* appCode default = project, sehingga agregator = frontend/payload/<project>.json.
|
|
667
|
+
*/
|
|
668
|
+
function runFrontendPipeline(ctx) {
|
|
669
|
+
const cfgArg = `--config=${ctx.configFlag}`;
|
|
670
|
+
const frontendDir = path.join(ctx.cwd, 'frontend');
|
|
671
|
+
const appCode = ctx.project;
|
|
672
|
+
|
|
673
|
+
phase('[F1/3] Migrate RDF -> UDF (per tabel, agregator di-akumulasi)');
|
|
674
|
+
fs.mkdirSync(path.join(frontendDir, 'payload'), { recursive: true });
|
|
675
|
+
for (const t of ctx.tableEntries) {
|
|
676
|
+
// Lewati tabel yang murni FK-parent (tanpa FK sendiri): page-nya dibuat
|
|
677
|
+
// otomatis via JOIN auto-discovery dari child (lihat scenario-12).
|
|
678
|
+
const isPureParent = t.fks.length === 0 && ctx.parentTables.has(t.table);
|
|
679
|
+
if (isPureParent) {
|
|
680
|
+
console.log(` (skip ${t.kebab}: auto-discovered via JOIN from child)`);
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
run(`npx restforge payload migrate --project=${ctx.project} --name=${t.kebab}.json --output=frontend/payload ${cfgArg} --port=${ctx.cfg.WEB_SERVER_PORT} --overwrite`, ctx.cwd);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
phase('[F2/3] Activate restforge-designer');
|
|
687
|
+
run(`restforge-designer activate --key=${ctx.cfg.LICENSE}`, frontendDir, { allowNonZero: true });
|
|
688
|
+
|
|
689
|
+
phase('[F3/3] Generate frontend application');
|
|
690
|
+
// Hapus index.html lama agar landing page diregenerasi sesuai set page terbaru.
|
|
691
|
+
run(`if exist apps\\${ctx.project}\\index.html del /Q apps\\${ctx.project}\\index.html`, frontendDir, { allowNonZero: true });
|
|
692
|
+
run(`restforge-designer generate --payload=payload/${appCode}.json --output=./apps/${ctx.project} --overwrite`, frontendDir);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Tulis launcher start REST API mandiri di folder kerja (lokasi pemanggilan
|
|
697
|
+
* fast-track). Windows -> server-start.bat, Linux/macOS -> server-start.sh.
|
|
698
|
+
* Perintah serve sama dengan maybeRunServer (+ --watch), selaras dengan
|
|
699
|
+
* referensi backend-v4/server-start.bat. NODE_ENV sengaja tidak di-set: shell
|
|
700
|
+
* biasa membiarkan NODE_ENV kosong sehingga logger memakai pino-pretty (rapi).
|
|
701
|
+
*/
|
|
702
|
+
function writeServerStartScript(ctx) {
|
|
703
|
+
const serveCmd = `npx restforge serve --project=${ctx.project} --config=${ctx.configFlag} --watch`;
|
|
704
|
+
const isWin = process.platform === 'win32';
|
|
705
|
+
const file = path.join(ctx.cwd, isWin ? 'server-start.bat' : 'server-start.sh');
|
|
706
|
+
let content;
|
|
707
|
+
if (isWin) {
|
|
708
|
+
content = [
|
|
709
|
+
'@echo off',
|
|
710
|
+
'REM Start RESTForge REST API (runtime server). Generated by fast-track.',
|
|
711
|
+
'cd /d "%~dp0"',
|
|
712
|
+
`call ${serveCmd}`,
|
|
713
|
+
''
|
|
714
|
+
].join('\r\n');
|
|
715
|
+
} else {
|
|
716
|
+
content = [
|
|
717
|
+
'#!/usr/bin/env bash',
|
|
718
|
+
'# Start RESTForge REST API (runtime server). Generated by fast-track.',
|
|
719
|
+
'set -e',
|
|
720
|
+
'cd "$(dirname "$0")"',
|
|
721
|
+
serveCmd,
|
|
722
|
+
''
|
|
723
|
+
].join('\n');
|
|
724
|
+
}
|
|
725
|
+
fs.writeFileSync(file, content);
|
|
726
|
+
if (!isWin) {
|
|
727
|
+
try { fs.chmodSync(file, 0o755); } catch { /* abaikan bila FS tak dukung chmod */ }
|
|
728
|
+
}
|
|
729
|
+
return file;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function printFinalSummary(ctx) {
|
|
733
|
+
const parts = [];
|
|
734
|
+
if (ctx.scope.backend) parts.push('REST API generated');
|
|
735
|
+
if (ctx.scope.frontend) parts.push('frontend app generated');
|
|
736
|
+
console.log('');
|
|
737
|
+
console.log(rule('='));
|
|
738
|
+
console.log(` DONE — ${parts.join(' + ')} [scope: ${ctx.scope.label}]`);
|
|
739
|
+
console.log(rule('='));
|
|
740
|
+
if (ctx.scope.backend) {
|
|
741
|
+
console.log(` Backend : payload/ , examples/${ctx.project}/ (${ctx.tableEntries.length} endpoint)`);
|
|
742
|
+
if (ctx.serverStartFile) {
|
|
743
|
+
console.log(` Start : ${path.basename(ctx.serverStartFile)} (start REST API manually)`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
if (ctx.scope.frontend) {
|
|
747
|
+
console.log(` Frontend : frontend/apps/${ctx.project}/ (start: npx serve . -l ${ctx.cfg.WEB_SERVER_PORT})`);
|
|
748
|
+
}
|
|
749
|
+
console.log(rule('='));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Konfirmasi lalu jalankan runtime server di window CMD baru. */
|
|
753
|
+
async function maybeRunServer(ctx, ask) {
|
|
754
|
+
// Samakan dengan pola server-start.bat: serve + --watch (auto-restart pada
|
|
755
|
+
// perubahan src/). Format log rapi (pino-pretty) berasal dari NODE_ENV
|
|
756
|
+
// development yang di-set saat spawn di bawah.
|
|
757
|
+
const serveCmd = `npx restforge serve --project=${ctx.project} --config=${ctx.configFlag} --watch`;
|
|
758
|
+
console.log('');
|
|
759
|
+
const answer = (await ask(' Run Runtime Server now in a new window? (Y/n): ')).trim().toLowerCase();
|
|
760
|
+
if (answer === 'n' || answer === 'no') {
|
|
761
|
+
console.log(` Skipped. Start later: ${serveCmd}`);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
freePort(ctx.cfg.SERVER_PORT);
|
|
765
|
+
const title = `RESTForge Server - ${ctx.project}`;
|
|
766
|
+
console.log(`\n Opening new window: "${title}"`);
|
|
767
|
+
console.log(`#${serveCmd}`);
|
|
768
|
+
// server.js (dispatcher) memaksa NODE_ENV=production untuk proses cli command,
|
|
769
|
+
// sehingga fast-track berjalan production dan secara default akan mewariskannya
|
|
770
|
+
// ke serve -> logger memakai format JSON mentah (verbose). Set development agar
|
|
771
|
+
// runtime server memakai pino-pretty (rapi), konsisten dengan playbook yang
|
|
772
|
+
// menjalankan serve dari plain `node`.
|
|
773
|
+
const serveEnv = { ...process.env, NODE_ENV: 'development' };
|
|
774
|
+
const r = spawnSync('cmd', ['/C', 'start', title, 'cmd', '/k', serveCmd], { cwd: ctx.cwd, stdio: 'inherit', env: serveEnv });
|
|
775
|
+
if (r.error) {
|
|
776
|
+
console.log(` Failed to open server window: ${r.error.message}`);
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
console.log(' ✓ Server window opened. Keep it open. Stop with Ctrl+C.');
|
|
780
|
+
|
|
781
|
+
// Health check: tunggu runtime benar-benar siap menerima request sebelum
|
|
782
|
+
// lanjut (mis. ke frontend). URL = banner runtime: /api/<project>/health.
|
|
783
|
+
const host = healthHost(ctx.cfg.SERVER_ADDRESS);
|
|
784
|
+
const healthUrl = `http://${host}:${ctx.cfg.SERVER_PORT}/api/${ctx.project}/health`;
|
|
785
|
+
console.log(`\n Waiting for server health: ${healthUrl}`);
|
|
786
|
+
const h = await waitForHealth(healthUrl, { timeoutMs: 30000, intervalMs: 600 });
|
|
787
|
+
if (h.ok) {
|
|
788
|
+
console.log(` ✓ Server healthy (${(h.elapsedMs / 1000).toFixed(1)}s).`);
|
|
789
|
+
} else {
|
|
790
|
+
console.log(` ⚠ Health check timed out after ${(h.elapsedMs / 1000).toFixed(0)}s.`);
|
|
791
|
+
console.log(' Server may still be starting; check the server window for errors.');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** Konfirmasi lalu jalankan aplikasi frontend (app-start.bat) di window CMD baru. */
|
|
796
|
+
async function maybeRunFrontend(ctx, ask) {
|
|
797
|
+
const appDir = path.join(ctx.cwd, 'frontend', 'apps', ctx.project);
|
|
798
|
+
const webPort = ctx.cfg.WEB_SERVER_PORT;
|
|
799
|
+
// Jalankan langsung `npx serve . -l <port>` (identik untuk Windows & Linux),
|
|
800
|
+
// tidak bergantung pada app-start.bat/.sh. File launcher itu urusan generator.
|
|
801
|
+
const serveCmd = `npx serve . -l ${webPort}`;
|
|
802
|
+
console.log('');
|
|
803
|
+
const answer = (await ask(' Run Frontend Application now in a new window? (Y/n): ')).trim().toLowerCase();
|
|
804
|
+
if (answer === 'n' || answer === 'no') {
|
|
805
|
+
console.log(` Skipped. Start later (in ${appDir}): ${serveCmd}`);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const indexHtml = path.join(appDir, 'index.html');
|
|
809
|
+
if (!fs.existsSync(indexHtml)) {
|
|
810
|
+
console.log(` Frontend app not found: ${indexHtml}`);
|
|
811
|
+
console.log(' Frontend generation may have failed; cannot launch.');
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
freePort(webPort);
|
|
815
|
+
const title = `RESTForge Frontend - ${ctx.project}`;
|
|
816
|
+
console.log(`\n Opening new window: "${title}"`);
|
|
817
|
+
console.log(`#${serveCmd}`);
|
|
818
|
+
const r = spawnSync('cmd', ['/C', 'start', title, 'cmd', '/k', serveCmd], { cwd: appDir, stdio: 'inherit' });
|
|
819
|
+
if (r.error) {
|
|
820
|
+
console.log(` Failed to open frontend window: ${r.error.message}`);
|
|
821
|
+
} else {
|
|
822
|
+
console.log(` ✓ Frontend window opened (WEB_SERVER_PORT ${webPort}).`);
|
|
823
|
+
console.log(` Open: http://localhost:${webPort}/index.html`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ---------------------------------------------------------------------------
|
|
828
|
+
// Contract
|
|
829
|
+
// ---------------------------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
module.exports = {
|
|
832
|
+
verb: 'fast-track',
|
|
833
|
+
description: 'Generate REST API + frontend app dari SDF (eksekusi nyata) + opsi runtime server',
|
|
834
|
+
category: 'generation',
|
|
835
|
+
flags: {
|
|
836
|
+
project: {
|
|
837
|
+
type: 'string',
|
|
838
|
+
required: true,
|
|
839
|
+
description: 'Nama project/aplikasi (mis. visitors-app)'
|
|
840
|
+
},
|
|
841
|
+
schema: {
|
|
842
|
+
type: 'string',
|
|
843
|
+
required: true,
|
|
844
|
+
description: 'Folder berisi SDF aplikasi (relatif cwd)'
|
|
845
|
+
},
|
|
846
|
+
config: {
|
|
847
|
+
type: 'string',
|
|
848
|
+
required: false,
|
|
849
|
+
default: DEFAULT_CONFIG_FILE,
|
|
850
|
+
description: 'Nama file env target di folder config/ (ditulis dari input; default db-connection.env)'
|
|
851
|
+
},
|
|
852
|
+
license: {
|
|
853
|
+
type: 'string',
|
|
854
|
+
required: false,
|
|
855
|
+
default: null,
|
|
856
|
+
description: 'License key (XXXX-XXXX-XXXX-XXXX). Bila diisi dipakai sebagai default prompt LICENSE'
|
|
857
|
+
},
|
|
858
|
+
overwrite: {
|
|
859
|
+
type: 'boolean',
|
|
860
|
+
required: false,
|
|
861
|
+
default: false,
|
|
862
|
+
description: 'Mode destruktif: drop table & regenerate (perlu konfirmasi y/N)'
|
|
863
|
+
},
|
|
864
|
+
'sim-no-designer': {
|
|
865
|
+
type: 'boolean',
|
|
866
|
+
required: false,
|
|
867
|
+
default: false,
|
|
868
|
+
description: '[preview] Paksa cabang dialog "restforge-designer NOT FOUND" (scope frontend)'
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
examples: [
|
|
872
|
+
'npx restforge fast-track --project=visitors-app --schema=./schema',
|
|
873
|
+
'npx restforge fast-track --project=visitors-app --schema=./schema --config=db-connection.env',
|
|
874
|
+
'npx restforge fast-track --project=visitors-app --schema=./schema --overwrite'
|
|
875
|
+
],
|
|
876
|
+
async handler(args) {
|
|
877
|
+
const cwd = process.cwd();
|
|
878
|
+
const schemaDir = path.resolve(cwd, args.schema);
|
|
879
|
+
const { names: tables } = collectTables(schemaDir);
|
|
880
|
+
|
|
881
|
+
const prompter = createPrompter();
|
|
882
|
+
try {
|
|
883
|
+
// 1) Input konfigurasi (LICENSE + database), gaya fast-track.mjs.
|
|
884
|
+
const cfg = await collectConfig(args, prompter.ask);
|
|
885
|
+
|
|
886
|
+
// 2) Menu pemilihan scope generate (REST API / frontend / all).
|
|
887
|
+
const scope = await selectScope(prompter.ask);
|
|
888
|
+
|
|
889
|
+
// 3) Preflight designer hanya bila scope mencakup frontend.
|
|
890
|
+
if (scope.frontend) {
|
|
891
|
+
checkDesigner(args);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const ctx = {
|
|
895
|
+
cwd,
|
|
896
|
+
project: args.project,
|
|
897
|
+
schemaFlag: args.schema,
|
|
898
|
+
configFlag: args.config,
|
|
899
|
+
tables,
|
|
900
|
+
cfg,
|
|
901
|
+
scope,
|
|
902
|
+
overwrite: args.overwrite === true
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// Backend dan frontend sama-sama butuh daftar tabel dari folder SDF
|
|
906
|
+
// (frontend memetakan tiap tabel ke page UDF via payload-nya).
|
|
907
|
+
if (scope.backend || scope.frontend) {
|
|
908
|
+
if (!fs.existsSync(schemaDir) || !fs.statSync(schemaDir).isDirectory()) {
|
|
909
|
+
console.log(`\n ERROR: schema folder not found: ${schemaDir}`);
|
|
910
|
+
throw stop('schema folder not found');
|
|
911
|
+
}
|
|
912
|
+
ctx.tableEntries = buildTableEntries(schemaDir);
|
|
913
|
+
if (ctx.tableEntries.length === 0) {
|
|
914
|
+
console.log(`\n ERROR: no SDF (.js) files in: ${schemaDir}`);
|
|
915
|
+
throw stop('no SDF files');
|
|
916
|
+
}
|
|
917
|
+
// Relasi FK lintas-tabel: peta tabel + himpunan tabel parent.
|
|
918
|
+
ctx.byTable = new Map(ctx.tableEntries.map((e) => [e.table, e]));
|
|
919
|
+
ctx.parentTables = new Set();
|
|
920
|
+
for (const e of ctx.tableEntries) {
|
|
921
|
+
for (const fk of e.fks) ctx.parentTables.add(fk.parentTable);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// 4) Preview + konfirmasi (sesuai mode).
|
|
926
|
+
if (ctx.overwrite) {
|
|
927
|
+
await confirmOverwriteMode(ctx, prompter.ask);
|
|
928
|
+
} else {
|
|
929
|
+
await confirmDefaultMode(ctx, prompter.ask);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// 5) Eksekusi sesuai scope.
|
|
933
|
+
if (ctx.scope.backend) {
|
|
934
|
+
runBackendPipeline(ctx);
|
|
935
|
+
// Launcher start REST API mandiri (sesuai OS), di folder kerja.
|
|
936
|
+
ctx.serverStartFile = writeServerStartScript(ctx);
|
|
937
|
+
}
|
|
938
|
+
if (ctx.scope.frontend) {
|
|
939
|
+
runFrontendPipeline(ctx);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
printFinalSummary(ctx);
|
|
943
|
+
|
|
944
|
+
// 6) Tawarkan menjalankan service: runtime server (backend) lalu
|
|
945
|
+
// aplikasi frontend, masing-masing di window CMD baru.
|
|
946
|
+
if (ctx.scope.backend) {
|
|
947
|
+
await maybeRunServer(ctx, prompter.ask);
|
|
948
|
+
}
|
|
949
|
+
if (ctx.scope.frontend) {
|
|
950
|
+
await maybeRunFrontend(ctx, prompter.ask);
|
|
951
|
+
}
|
|
952
|
+
} finally {
|
|
953
|
+
prompter.close();
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
|
|
958
|
+
// Test seam (hanya aktif via env): mengekspos helper internal agar dapat diuji
|
|
959
|
+
// terpisah tanpa menjalankan pipeline penuh. Pada penggunaan CLI normal env ini
|
|
960
|
+
// tidak di-set sehingga export tetap berupa contract murni.
|
|
961
|
+
if (process.env.FASTTRACK_TEST === '1') {
|
|
962
|
+
module.exports.__test = { parseModel, buildTableEntries, fkColumnsForEntry };
|
|
963
|
+
}
|