@restforgejs/platform 5.0.9 → 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.
Files changed (170) hide show
  1. package/build-info.json +2 -2
  2. package/cli/consumer-deploy.js +1 -1
  3. package/cli/consumer.js +1 -1
  4. package/generators/cli/fast-track.js +963 -0
  5. package/generators/cli/payload/sync.js +18 -2
  6. package/generators/lib/migrate/field-type-resolver.js +18 -5
  7. package/generators/lib/payload/payload-runner.js +724 -39
  8. package/generators/lib/templates/dashboard-catalog.js +1 -1
  9. package/generators/lib/templates/db-connection-env.js +1 -1
  10. package/generators/lib/templates/dbschema-catalog.js +1 -1
  11. package/generators/lib/templates/field-validation-catalog.js +1 -1
  12. package/generators/lib/templates/mysql-template.js +1 -1
  13. package/generators/lib/templates/oracle-template.js +1 -1
  14. package/generators/lib/templates/postgres-template.js +1 -1
  15. package/generators/lib/templates/query-declarative-catalog.js +1 -1
  16. package/generators/lib/templates/sqlite-template.js +1 -1
  17. package/integrity-manifest.json +18 -18
  18. package/package.json +1 -1
  19. package/scripts/verify-integrity.js +1 -1
  20. package/server.js +1 -1
  21. package/src/components/handlers/adjust_handler.js +1 -1
  22. package/src/components/handlers/audit_handler.js +1 -1
  23. package/src/components/handlers/delete_handler.js +1 -1
  24. package/src/components/handlers/export_handler.js +1 -1
  25. package/src/components/handlers/import_handler.js +1 -1
  26. package/src/components/handlers/insert_handler.js +1 -1
  27. package/src/components/handlers/update_handler.js +1 -1
  28. package/src/components/handlers/upload_handler.js +1 -1
  29. package/src/components/handlers/workflow_handler.js +1 -1
  30. package/src/components/integrations/webhook.js +1 -1
  31. package/src/consumers/baseConsumer.js +1 -1
  32. package/src/consumers/declarativeMapper.js +1 -1
  33. package/src/consumers/handlers/apiHandler.js +1 -1
  34. package/src/consumers/handlers/consoleHandler.js +1 -1
  35. package/src/consumers/handlers/databaseHandler.js +1 -1
  36. package/src/consumers/handlers/index.js +1 -1
  37. package/src/consumers/handlers/kafkaHandler.js +1 -1
  38. package/src/consumers/index.js +1 -1
  39. package/src/consumers/messageTransformer.js +1 -1
  40. package/src/consumers/validator.js +1 -1
  41. package/src/core/db/dialect/base-dialect.js +1 -1
  42. package/src/core/db/dialect/index.js +1 -1
  43. package/src/core/db/dialect/mysql-dialect.js +1 -1
  44. package/src/core/db/dialect/oracle-dialect.js +1 -1
  45. package/src/core/db/dialect/postgres-dialect.js +1 -1
  46. package/src/core/db/dialect/sqlite-dialect.js +1 -1
  47. package/src/core/db/flatten-helper.js +1 -1
  48. package/src/core/db/query-builder-error.js +1 -1
  49. package/src/core/db/query-builder.js +1 -1
  50. package/src/core/db/relation-helper.js +1 -1
  51. package/src/core/handlers/delete_handler.js +1 -1
  52. package/src/core/handlers/insert_handler.js +1 -1
  53. package/src/core/handlers/update_handler.js +1 -1
  54. package/src/core/models/base-model.js +1 -1
  55. package/src/core/utils/cache-manager.js +1 -1
  56. package/src/core/utils/component-engine.js +1 -1
  57. package/src/core/utils/context-builder.js +1 -1
  58. package/src/core/utils/datetime-formatter.js +1 -1
  59. package/src/core/utils/datetime-parser.js +1 -1
  60. package/src/core/utils/db.js +1 -1
  61. package/src/core/utils/logger.js +1 -1
  62. package/src/core/utils/payload-loader.js +1 -1
  63. package/src/core/utils/security-checks.js +1 -1
  64. package/src/middleware/body-options.js +1 -1
  65. package/src/middleware/cors.js +1 -1
  66. package/src/middleware/idempotency.js +1 -1
  67. package/src/middleware/rate-limiter.js +1 -1
  68. package/src/middleware/request-logger.js +1 -1
  69. package/src/middleware/security-headers.js +1 -1
  70. package/src/models/base-model-mysql.js +1 -1
  71. package/src/models/base-model-oracle.js +1 -1
  72. package/src/models/base-model-sqlite.js +1 -1
  73. package/src/models/base-model.js +1 -1
  74. package/src/pro/caching/redis-client.js +1 -1
  75. package/src/pro/caching/redis-helper.js +1 -1
  76. package/src/pro/consumers/baseConsumer.js +1 -1
  77. package/src/pro/consumers/declarativeMapper.js +1 -1
  78. package/src/pro/consumers/handlers/apiHandler.js +1 -1
  79. package/src/pro/consumers/handlers/consoleHandler.js +1 -1
  80. package/src/pro/consumers/handlers/databaseHandler.js +1 -1
  81. package/src/pro/consumers/handlers/index.js +1 -1
  82. package/src/pro/consumers/handlers/kafkaHandler.js +1 -1
  83. package/src/pro/consumers/index.js +1 -1
  84. package/src/pro/consumers/messageTransformer.js +1 -1
  85. package/src/pro/consumers/validator.js +1 -1
  86. package/src/pro/database/base-model-mysql.js +1 -1
  87. package/src/pro/database/base-model-oracle.js +1 -1
  88. package/src/pro/database/base-model-sqlite.js +1 -1
  89. package/src/pro/database/db-mysql.js +1 -1
  90. package/src/pro/database/db-oracle.js +1 -1
  91. package/src/pro/database/db-sqlite.js +1 -1
  92. package/src/pro/excel/excel-generator.js +1 -1
  93. package/src/pro/excel/excel-parser.js +1 -1
  94. package/src/pro/excel/export-service.js +1 -1
  95. package/src/pro/excel/export_handler.js +1 -1
  96. package/src/pro/excel/import-service.js +1 -1
  97. package/src/pro/excel/import-validator.js +1 -1
  98. package/src/pro/excel/import_handler.js +1 -1
  99. package/src/pro/excel/upsert-builder.js +1 -1
  100. package/src/pro/idgen/idgen-routes.js +1 -1
  101. package/src/pro/integrations/lookup-resolver.js +1 -1
  102. package/src/pro/integrations/upload-handler-v2.js +1 -1
  103. package/src/pro/integrations/upload-handler.js +1 -1
  104. package/src/pro/integrations/webhook.js +1 -1
  105. package/src/pro/locking/lock-routes.js +1 -1
  106. package/src/pro/locking/resource-lock-manager.js +1 -1
  107. package/src/pro/messaging/kafkaConsumerService.js +1 -1
  108. package/src/pro/messaging/kafkaService.js +1 -1
  109. package/src/pro/messaging/messagehubService.js +1 -1
  110. package/src/pro/messaging/rabbitmqService.js +1 -1
  111. package/src/pro/scheduler/job-manager.js +1 -1
  112. package/src/pro/scheduler/job-routes.js +1 -1
  113. package/src/pro/scheduler/job-validator.js +1 -1
  114. package/src/pro/storage/base-storage-provider.js +1 -1
  115. package/src/pro/storage/file-metadata-helper.js +1 -1
  116. package/src/pro/storage/index.js +1 -1
  117. package/src/pro/storage/local-storage-provider.js +1 -1
  118. package/src/pro/storage/s3-storage-provider.js +1 -1
  119. package/src/pro/storage/upload-cleanup-job.js +1 -1
  120. package/src/pro/storage/upload-cleanup-scheduler.js +1 -1
  121. package/src/pro/storage/upload-pending-tracker.js +1 -1
  122. package/src/pro/websocket/broadcast-helper.js +1 -1
  123. package/src/pro/websocket/index.js +1 -1
  124. package/src/pro/websocket/livesync-server.js +1 -1
  125. package/src/pro/websocket/ws-broadcaster.js +1 -1
  126. package/src/services/export-service.js +1 -1
  127. package/src/services/import-service.js +1 -1
  128. package/src/services/kafkaConsumerService.js +1 -1
  129. package/src/services/kafkaService.js +1 -1
  130. package/src/services/messagehubService.js +1 -1
  131. package/src/services/rabbitmqService.js +1 -1
  132. package/src/utils/cache-invalidation-registry.js +1 -1
  133. package/src/utils/cache-manager.js +1 -1
  134. package/src/utils/component-engine.js +1 -1
  135. package/src/utils/config-extractor.js +1 -1
  136. package/src/utils/consumerLogger.js +1 -1
  137. package/src/utils/context-builder.js +1 -1
  138. package/src/utils/dashboard-helpers.js +1 -1
  139. package/src/utils/dateHelper.js +1 -1
  140. package/src/utils/datetime-formatter.js +1 -1
  141. package/src/utils/datetime-parser.js +1 -1
  142. package/src/utils/db-bootstrap.js +1 -1
  143. package/src/utils/db-mysql.js +1 -1
  144. package/src/utils/db-oracle.js +1 -1
  145. package/src/utils/db-sqlite.js +1 -1
  146. package/src/utils/db.js +1 -1
  147. package/src/utils/demo-generator.js +1 -1
  148. package/src/utils/excel-generator.js +1 -1
  149. package/src/utils/excel-parser.js +1 -1
  150. package/src/utils/file-watcher.js +1 -1
  151. package/src/utils/id-generator.js +1 -1
  152. package/src/utils/idempotency-manager.js +1 -1
  153. package/src/utils/import-validator.js +1 -1
  154. package/src/utils/license-client.js +1 -1
  155. package/src/utils/lock-manager.js +1 -1
  156. package/src/utils/logger.js +1 -1
  157. package/src/utils/lookup-resolver.js +1 -1
  158. package/src/utils/payload-loader.js +1 -1
  159. package/src/utils/processor-response.js +1 -1
  160. package/src/utils/rabbitmq.js +1 -1
  161. package/src/utils/redis-client.js +1 -1
  162. package/src/utils/redis-helper.js +1 -1
  163. package/src/utils/request-scope.js +1 -1
  164. package/src/utils/security-checks.js +1 -1
  165. package/src/utils/service-resolver.js +1 -1
  166. package/src/utils/shutdown-coordinator.js +1 -1
  167. package/src/utils/trusted-keys.js +1 -1
  168. package/src/utils/upload-handler.js +1 -1
  169. package/src/utils/upsert-builder.js +1 -1
  170. 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
+ }