db-model-router 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +283 -25
- package/TODO.md +14 -0
- package/dbmr.schema.json +333 -0
- package/demo/.dockerignore +7 -0
- package/demo/.env.example +13 -0
- package/demo/Dockerfile +20 -0
- package/demo/app.js +37 -0
- package/demo/commons/add_migration.js +43 -0
- package/demo/commons/db.js +17 -0
- package/demo/commons/migrate.js +65 -0
- package/demo/commons/security.js +30 -0
- package/demo/commons/session.js +13 -0
- package/demo/dbmr.schema.json +362 -0
- package/demo/docs/llm.md +197 -0
- package/demo/llms.txt +70 -0
- package/demo/middleware/logger.js +67 -0
- package/demo/migrations/20260430155808_create_migrations_table.sql +6 -0
- package/demo/migrations/20260430155809_create_tables.sql +207 -0
- package/demo/models/addresses.js +22 -0
- package/demo/models/cart_items.js +18 -0
- package/demo/models/carts.js +16 -0
- package/demo/models/categories.js +20 -0
- package/demo/models/coupons.js +23 -0
- package/demo/models/order_items.js +21 -0
- package/demo/models/orders.js +25 -0
- package/demo/models/payments.js +21 -0
- package/demo/models/product_images.js +18 -0
- package/demo/models/product_reviews.js +20 -0
- package/demo/models/product_variants.js +20 -0
- package/demo/models/products.js +30 -0
- package/demo/models/shipments.js +19 -0
- package/demo/models/users.js +19 -0
- package/demo/models/wishlists.js +15 -0
- package/demo/openapi.json +5872 -0
- package/demo/package-lock.json +2810 -0
- package/demo/package.json +34 -0
- package/demo/routes/addresses.js +6 -0
- package/demo/routes/carts/cart_items.js +7 -0
- package/demo/routes/carts.js +6 -0
- package/demo/routes/categories.js +6 -0
- package/demo/routes/coupons.js +6 -0
- package/demo/routes/docs.js +18 -0
- package/demo/routes/health.js +35 -0
- package/demo/routes/index.js +39 -0
- package/demo/routes/orders/order_items.js +7 -0
- package/demo/routes/orders/payments.js +7 -0
- package/demo/routes/orders/shipments.js +7 -0
- package/demo/routes/orders.js +6 -0
- package/demo/routes/products/product_images.js +7 -0
- package/demo/routes/products/product_reviews.js +7 -0
- package/demo/routes/products/product_variants.js +7 -0
- package/demo/routes/products.js +6 -0
- package/demo/routes/users.js +6 -0
- package/demo/routes/wishlists.js +6 -0
- package/docker-compose.yml +1 -1
- package/package.json +16 -7
- package/scripts/demo-create.js +47 -0
- package/skill/SKILL.md +464 -0
- package/skill/references/cockroachdb.md +49 -0
- package/skill/references/dynamodb.md +53 -0
- package/skill/references/mongodb.md +56 -0
- package/skill/references/mssql.md +55 -0
- package/skill/references/oracle.md +52 -0
- package/skill/references/postgres.md +50 -0
- package/skill/references/redis.md +53 -0
- package/skill/references/sqlite3.md +43 -0
- package/src/cli/commands/generate.js +58 -17
- package/src/cli/commands/help.js +185 -0
- package/src/cli/commands/init.js +42 -14
- package/src/cli/commands/inspect.js +21 -3
- package/src/cli/diff-engine.js +52 -22
- package/src/cli/generate-docs-route.js +31 -0
- package/src/cli/generate-migration.js +356 -0
- package/src/cli/generate-model.js +5 -4
- package/src/cli/generate-route.js +79 -45
- package/src/cli/init/dependencies.js +17 -5
- package/src/cli/init/generators.js +1073 -64
- package/src/cli/init/prompt.js +37 -5
- package/src/cli/init.js +148 -25
- package/src/cli/main.js +90 -10
- package/src/cockroachdb/db.js +90 -59
- package/src/commons/route.js +20 -20
- package/src/commons/validator.js +58 -1
- package/src/dynamodb/db.js +50 -27
- package/src/index.js +2 -0
- package/src/mongodb/db.js +1 -0
- package/src/mssql/db.js +89 -61
- package/src/mysql/db.js +1 -0
- package/src/oracle/db.js +1 -0
- package/src/postgres/db.js +61 -41
- package/src/redis/db.js +1 -0
- package/src/schema/schema-parser.js +43 -1
- package/src/schema/schema-printer.js +8 -5
- package/src/schema/schema-to-meta.js +4 -0
- package/src/schema/schema-validator.js +20 -1
- package/src/sqlite3/db.js +1 -0
- package/docs/SKILL.md +0 -374
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const SQL_DATABASES = [
|
|
4
4
|
"mysql",
|
|
5
|
+
"mariadb",
|
|
5
6
|
"postgres",
|
|
6
7
|
"sqlite3",
|
|
7
8
|
"mssql",
|
|
@@ -54,6 +55,13 @@ const DB_ENV_MAP = {
|
|
|
54
55
|
{ key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
|
|
55
56
|
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
56
57
|
],
|
|
58
|
+
mariadb: [
|
|
59
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
60
|
+
{ key: "DB_PORT", defaultValue: "3306", placeholder: "3306" },
|
|
61
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
62
|
+
{ key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
|
|
63
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
64
|
+
],
|
|
57
65
|
postgres: [
|
|
58
66
|
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
59
67
|
{ key: "DB_PORT", defaultValue: "5432", placeholder: "5432" },
|
|
@@ -69,7 +77,11 @@ const DB_ENV_MAP = {
|
|
|
69
77
|
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
70
78
|
],
|
|
71
79
|
sqlite3: [
|
|
72
|
-
{
|
|
80
|
+
{
|
|
81
|
+
key: "DB_NAME",
|
|
82
|
+
defaultValue: "./data/data.db",
|
|
83
|
+
placeholder: "./data/data.db",
|
|
84
|
+
},
|
|
73
85
|
],
|
|
74
86
|
mongodb: [
|
|
75
87
|
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
@@ -123,13 +135,36 @@ const REDIS_SESSION_VARS = [
|
|
|
123
135
|
{ key: "REDIS_PASS", defaultValue: "", placeholder: "your_password" },
|
|
124
136
|
];
|
|
125
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Generate a random alphanumeric password.
|
|
140
|
+
* @param {number} [length=24]
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function randomPassword(length) {
|
|
144
|
+
const chars =
|
|
145
|
+
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
146
|
+
const len = length || 24;
|
|
147
|
+
let result = "";
|
|
148
|
+
const crypto = require("crypto");
|
|
149
|
+
const bytes = crypto.randomBytes(len);
|
|
150
|
+
for (let i = 0; i < len; i++) {
|
|
151
|
+
result += chars[bytes[i] % chars.length];
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
126
156
|
/**
|
|
127
157
|
* Build env file content from the config map.
|
|
128
158
|
* @param {import('./types').InitAnswers} answers
|
|
129
159
|
* @param {'default'|'placeholder'} mode
|
|
160
|
+
* @param {object} [secrets] - generated secrets to keep in sync with docker-compose
|
|
161
|
+
* @param {string} [secrets.dbPass] - database password
|
|
162
|
+
* @param {string} [secrets.redisPass] - redis session password
|
|
163
|
+
* @param {string} [secrets.sessionSecret] - session secret
|
|
130
164
|
* @returns {string}
|
|
131
165
|
*/
|
|
132
|
-
function buildEnvContent(answers, mode) {
|
|
166
|
+
function buildEnvContent(answers, mode, secrets) {
|
|
167
|
+
const s = secrets || {};
|
|
133
168
|
const pick = mode === "placeholder" ? "placeholder" : "defaultValue";
|
|
134
169
|
const lines = [];
|
|
135
170
|
lines.push("# Server");
|
|
@@ -139,14 +174,19 @@ function buildEnvContent(answers, mode) {
|
|
|
139
174
|
|
|
140
175
|
const vars = DB_ENV_MAP[answers.database] || [];
|
|
141
176
|
for (const v of vars) {
|
|
142
|
-
|
|
177
|
+
// Override password with generated secret in default mode
|
|
178
|
+
if (mode === "default" && v.key === "DB_PASS" && s.dbPass) {
|
|
179
|
+
lines.push(`${v.key}=${s.dbPass}`);
|
|
180
|
+
} else {
|
|
181
|
+
lines.push(`${v.key}=${v[pick]}`);
|
|
182
|
+
}
|
|
143
183
|
}
|
|
144
184
|
|
|
145
185
|
// Session secret
|
|
146
186
|
lines.push("");
|
|
147
187
|
lines.push("# Session");
|
|
148
188
|
lines.push(
|
|
149
|
-
`SESSION_SECRET=${mode === "placeholder" ? "your_session_secret" : "change-me"}`,
|
|
189
|
+
`SESSION_SECRET=${mode === "placeholder" ? "your_session_secret" : s.sessionSecret || "change-me"}`,
|
|
150
190
|
);
|
|
151
191
|
|
|
152
192
|
// Redis session env vars when session is redis and database is not redis
|
|
@@ -154,7 +194,29 @@ function buildEnvContent(answers, mode) {
|
|
|
154
194
|
lines.push("");
|
|
155
195
|
lines.push("# Redis Session");
|
|
156
196
|
for (const v of REDIS_SESSION_VARS) {
|
|
157
|
-
|
|
197
|
+
if (mode === "default" && v.key === "REDIS_PASS" && s.redisPass) {
|
|
198
|
+
lines.push(`${v.key}=${s.redisPass}`);
|
|
199
|
+
} else {
|
|
200
|
+
lines.push(`${v.key}=${v[pick]}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Logging
|
|
206
|
+
if (answers.logger) {
|
|
207
|
+
lines.push("");
|
|
208
|
+
lines.push("# Logging");
|
|
209
|
+
lines.push(
|
|
210
|
+
`APP_NAME=${mode === "placeholder" ? "your_app_name" : "my-app"}`,
|
|
211
|
+
);
|
|
212
|
+
lines.push(`LOG_LEVEL=${mode === "placeholder" ? "info" : "info"}`);
|
|
213
|
+
// LOKI_HOST: empty by default, set a URL to enable Loki transport
|
|
214
|
+
if (answers.loki && mode === "default") {
|
|
215
|
+
lines.push("LOKI_HOST=http://localhost:3100");
|
|
216
|
+
} else {
|
|
217
|
+
lines.push(
|
|
218
|
+
`LOKI_HOST=${mode === "placeholder" ? "http://your-loki-host:3100" : ""}`,
|
|
219
|
+
);
|
|
158
220
|
}
|
|
159
221
|
}
|
|
160
222
|
|
|
@@ -165,10 +227,11 @@ function buildEnvContent(answers, mode) {
|
|
|
165
227
|
/**
|
|
166
228
|
* Generate .env file content.
|
|
167
229
|
* @param {import('./types').InitAnswers} answers
|
|
230
|
+
* @param {object} [secrets] - generated secrets
|
|
168
231
|
* @returns {string}
|
|
169
232
|
*/
|
|
170
|
-
function generateEnvFile(answers) {
|
|
171
|
-
return buildEnvContent(answers, "default");
|
|
233
|
+
function generateEnvFile(answers, secrets) {
|
|
234
|
+
return buildEnvContent(answers, "default", secrets);
|
|
172
235
|
}
|
|
173
236
|
|
|
174
237
|
/**
|
|
@@ -209,7 +272,7 @@ function dbConnectBlock(database) {
|
|
|
209
272
|
}
|
|
210
273
|
if (database === "sqlite3") {
|
|
211
274
|
return `db.connect({
|
|
212
|
-
database: process.env.DB_NAME || "./data.db",
|
|
275
|
+
database: process.env.DB_NAME || "./data/data.db",
|
|
213
276
|
});`;
|
|
214
277
|
}
|
|
215
278
|
return `db.connect({
|
|
@@ -221,6 +284,40 @@ function dbConnectBlock(database) {
|
|
|
221
284
|
});`;
|
|
222
285
|
}
|
|
223
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Return just the connect config properties (indented, no wrapper).
|
|
289
|
+
* Used by generateDbModule where the caller controls the object name.
|
|
290
|
+
* @param {string} database
|
|
291
|
+
* @returns {string}
|
|
292
|
+
*/
|
|
293
|
+
function dbConnectArgs(database) {
|
|
294
|
+
if (database === "dynamodb") {
|
|
295
|
+
return ` region: process.env.AWS_REGION,
|
|
296
|
+
endpoint: process.env.AWS_ENDPOINT,
|
|
297
|
+
credentials: {
|
|
298
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
299
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
300
|
+
},
|
|
301
|
+
`;
|
|
302
|
+
}
|
|
303
|
+
if (database === "redis") {
|
|
304
|
+
return ` host: process.env.DB_HOST || "localhost",
|
|
305
|
+
port: process.env.DB_PORT || 6379,
|
|
306
|
+
password: process.env.DB_PASS,
|
|
307
|
+
`;
|
|
308
|
+
}
|
|
309
|
+
if (database === "sqlite3") {
|
|
310
|
+
return ` database: process.env.DB_NAME || "./data/data.db",
|
|
311
|
+
`;
|
|
312
|
+
}
|
|
313
|
+
return ` host: process.env.DB_HOST || "localhost",
|
|
314
|
+
port: process.env.DB_PORT,
|
|
315
|
+
database: process.env.DB_NAME,
|
|
316
|
+
user: process.env.DB_USER,
|
|
317
|
+
password: process.env.DB_PASS,
|
|
318
|
+
`;
|
|
319
|
+
}
|
|
320
|
+
|
|
224
321
|
/**
|
|
225
322
|
* Generate session middleware block for app.js.
|
|
226
323
|
* @param {import('./types').InitAnswers} answers
|
|
@@ -341,35 +438,79 @@ module.exports = app;
|
|
|
341
438
|
|
|
342
439
|
/**
|
|
343
440
|
* Generate middleware/logger.js content.
|
|
441
|
+
* When logger is enabled, uses Winston with winston-loki transport for Grafana.
|
|
344
442
|
* @param {import('./types').InitAnswers} answers
|
|
345
443
|
* @returns {string}
|
|
346
444
|
*/
|
|
347
445
|
function generateLoggerMiddleware(answers) {
|
|
348
446
|
if (answers.logger) {
|
|
349
|
-
return `
|
|
447
|
+
return `import winston from "winston";
|
|
350
448
|
|
|
351
449
|
/**
|
|
352
|
-
*
|
|
353
|
-
*
|
|
450
|
+
* Winston logger with Console transport.
|
|
451
|
+
* If LOKI_HOST is set in .env, adds a Loki transport for Grafana visualization.
|
|
354
452
|
*/
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
453
|
+
const transports = [
|
|
454
|
+
new winston.transports.Console({
|
|
455
|
+
format: winston.format.combine(
|
|
456
|
+
winston.format.colorize(),
|
|
457
|
+
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
|
458
|
+
const metaStr = Object.keys(meta).length > 1
|
|
459
|
+
? " " + JSON.stringify(meta)
|
|
460
|
+
: "";
|
|
461
|
+
return \`[\${timestamp}] [\${level}] \${message}\${metaStr}\`;
|
|
462
|
+
}),
|
|
463
|
+
),
|
|
464
|
+
}),
|
|
465
|
+
];
|
|
466
|
+
|
|
467
|
+
// Add Loki transport only when LOKI_HOST is configured
|
|
468
|
+
if (process.env.LOKI_HOST) {
|
|
469
|
+
const { default: LokiTransport } = await import("winston-loki");
|
|
470
|
+
transports.push(
|
|
471
|
+
new LokiTransport({
|
|
472
|
+
host: process.env.LOKI_HOST,
|
|
473
|
+
labels: { app: process.env.APP_NAME || "app" },
|
|
474
|
+
json: true,
|
|
475
|
+
onConnectionError: (err) => console.error("Loki connection error:", err),
|
|
476
|
+
}),
|
|
361
477
|
);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const logger = winston.createLogger({
|
|
481
|
+
level: process.env.LOG_LEVEL || "info",
|
|
482
|
+
format: winston.format.combine(
|
|
483
|
+
winston.format.timestamp(),
|
|
484
|
+
winston.format.json(),
|
|
485
|
+
),
|
|
486
|
+
defaultMeta: { service: process.env.APP_NAME || "app" },
|
|
487
|
+
transports,
|
|
365
488
|
});
|
|
366
489
|
|
|
367
|
-
|
|
368
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Express middleware that logs every request/response.
|
|
492
|
+
*/
|
|
493
|
+
function requestLogger(req, res, next) {
|
|
494
|
+
const start = Date.now();
|
|
495
|
+
|
|
496
|
+
res.on("finish", () => {
|
|
497
|
+
const duration = Date.now() - start;
|
|
498
|
+
const level = res.statusCode >= 400 ? "warn" : "info";
|
|
499
|
+
logger.log({
|
|
500
|
+
level,
|
|
501
|
+
message: \`\${req.method} \${req.originalUrl} \${res.statusCode} \${duration}ms\`,
|
|
502
|
+
method: req.method,
|
|
503
|
+
url: req.originalUrl,
|
|
504
|
+
status: res.statusCode,
|
|
505
|
+
duration,
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
369
509
|
next();
|
|
370
510
|
}
|
|
371
511
|
|
|
372
|
-
|
|
512
|
+
requestLogger.logger = logger;
|
|
513
|
+
export default requestLogger;
|
|
373
514
|
`;
|
|
374
515
|
}
|
|
375
516
|
|
|
@@ -377,7 +518,7 @@ module.exports = [startTimer, logger];
|
|
|
377
518
|
* Simple request logger middleware.
|
|
378
519
|
* Logs method, URL, status code, and response time.
|
|
379
520
|
*/
|
|
380
|
-
|
|
521
|
+
export default function logger(req, res, next) {
|
|
381
522
|
const start = Date.now();
|
|
382
523
|
const { method, originalUrl } = req;
|
|
383
524
|
|
|
@@ -391,7 +532,7 @@ module.exports = function logger(req, res, next) {
|
|
|
391
532
|
});
|
|
392
533
|
|
|
393
534
|
next();
|
|
394
|
-
}
|
|
535
|
+
}
|
|
395
536
|
`;
|
|
396
537
|
}
|
|
397
538
|
|
|
@@ -649,53 +790,41 @@ function generateInitialMigration(answers, date) {
|
|
|
649
790
|
// NoSQL databases
|
|
650
791
|
let content;
|
|
651
792
|
if (answers.database === "mongodb") {
|
|
652
|
-
content = `
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
await db.createCollection("_migrations");
|
|
657
|
-
await db.collection("_migrations").createIndex({ filename: 1 }, { unique: true });
|
|
658
|
-
},
|
|
793
|
+
content = `export async function up(db) {
|
|
794
|
+
await db.createCollection("_migrations");
|
|
795
|
+
await db.collection("_migrations").createIndex({ filename: 1 }, { unique: true });
|
|
796
|
+
}
|
|
659
797
|
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
};
|
|
798
|
+
export async function down(db) {
|
|
799
|
+
await db.collection("_migrations").drop();
|
|
800
|
+
}
|
|
664
801
|
`;
|
|
665
802
|
} else if (answers.database === "redis") {
|
|
666
|
-
content = `
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
// _migrations hash key will be created on first HSET
|
|
671
|
-
console.log("Redis migration tracking initialized using hash key: _migrations");
|
|
672
|
-
},
|
|
803
|
+
content = `export async function up(db) {
|
|
804
|
+
// _migrations hash key will be created on first HSET
|
|
805
|
+
console.log("Redis migration tracking initialized using hash key: _migrations");
|
|
806
|
+
}
|
|
673
807
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
};
|
|
808
|
+
export async function down(db) {
|
|
809
|
+
await db.del("_migrations");
|
|
810
|
+
}
|
|
678
811
|
`;
|
|
679
812
|
} else {
|
|
680
813
|
// dynamodb
|
|
681
|
-
content = `
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}));
|
|
692
|
-
},
|
|
814
|
+
content = `import { CreateTableCommand, DeleteTableCommand } from "@aws-sdk/client-dynamodb";
|
|
815
|
+
|
|
816
|
+
export async function up(db) {
|
|
817
|
+
await db.send(new CreateTableCommand({
|
|
818
|
+
TableName: "_migrations",
|
|
819
|
+
KeySchema: [{ AttributeName: "filename", KeyType: "HASH" }],
|
|
820
|
+
AttributeDefinitions: [{ AttributeName: "filename", AttributeType: "S" }],
|
|
821
|
+
BillingMode: "PAY_PER_REQUEST",
|
|
822
|
+
}));
|
|
823
|
+
}
|
|
693
824
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
},
|
|
698
|
-
};
|
|
825
|
+
export async function down(db) {
|
|
826
|
+
await db.send(new DeleteTableCommand({ TableName: "_migrations" }));
|
|
827
|
+
}
|
|
699
828
|
`;
|
|
700
829
|
}
|
|
701
830
|
|
|
@@ -754,6 +883,409 @@ CREATE INDEX idx_sessions_expired ON sessions(expired_at);
|
|
|
754
883
|
return { filename: `${ts}_create_sessions_table.sql`, content };
|
|
755
884
|
}
|
|
756
885
|
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
// Docker Compose generator
|
|
888
|
+
// ---------------------------------------------------------------------------
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Docker image and config for each supported database.
|
|
892
|
+
*/
|
|
893
|
+
const DOCKER_DB_MAP = {
|
|
894
|
+
mysql: {
|
|
895
|
+
image: "mysql:latest",
|
|
896
|
+
port: "3306:3306",
|
|
897
|
+
env: (secrets) => ({
|
|
898
|
+
MYSQL_ROOT_PASSWORD: secrets.dbPass,
|
|
899
|
+
MYSQL_DATABASE: "my_app",
|
|
900
|
+
}),
|
|
901
|
+
volumes: ["./data/mysql:/var/lib/mysql"],
|
|
902
|
+
},
|
|
903
|
+
mariadb: {
|
|
904
|
+
image: "mariadb:latest",
|
|
905
|
+
port: "3306:3306",
|
|
906
|
+
env: (secrets) => ({
|
|
907
|
+
MARIADB_ROOT_PASSWORD: secrets.dbPass,
|
|
908
|
+
MARIADB_DATABASE: "my_app",
|
|
909
|
+
}),
|
|
910
|
+
volumes: ["./data/mariadb:/var/lib/mysql"],
|
|
911
|
+
},
|
|
912
|
+
postgres: {
|
|
913
|
+
image: "postgres:alpine",
|
|
914
|
+
port: "5432:5432",
|
|
915
|
+
env: (secrets) => ({
|
|
916
|
+
POSTGRES_USER: "postgres",
|
|
917
|
+
POSTGRES_PASSWORD: secrets.dbPass,
|
|
918
|
+
POSTGRES_DB: "my_app",
|
|
919
|
+
}),
|
|
920
|
+
volumes: ["./data/postgres:/var/lib/postgresql/data"],
|
|
921
|
+
},
|
|
922
|
+
cockroachdb: {
|
|
923
|
+
image: "cockroachdb/cockroach:latest",
|
|
924
|
+
port: "26257:26257",
|
|
925
|
+
command: "start-single-node --insecure",
|
|
926
|
+
env: () => ({}),
|
|
927
|
+
volumes: ["./data/cockroachdb:/cockroach/cockroach-data"],
|
|
928
|
+
},
|
|
929
|
+
mongodb: {
|
|
930
|
+
image: "mongo:latest",
|
|
931
|
+
port: "27017:27017",
|
|
932
|
+
env: (secrets) => ({
|
|
933
|
+
MONGO_INITDB_ROOT_USERNAME: "root",
|
|
934
|
+
MONGO_INITDB_ROOT_PASSWORD: secrets.dbPass,
|
|
935
|
+
MONGO_INITDB_DATABASE: "my_app",
|
|
936
|
+
}),
|
|
937
|
+
volumes: ["./data/mongodb:/data/db"],
|
|
938
|
+
},
|
|
939
|
+
mssql: {
|
|
940
|
+
image: "mcr.microsoft.com/mssql/server:latest",
|
|
941
|
+
port: "1433:1433",
|
|
942
|
+
env: (secrets) => ({
|
|
943
|
+
ACCEPT_EULA: "Y",
|
|
944
|
+
MSSQL_SA_PASSWORD: secrets.dbPass,
|
|
945
|
+
}),
|
|
946
|
+
volumes: ["./data/mssql:/var/opt/mssql"],
|
|
947
|
+
},
|
|
948
|
+
oracle: {
|
|
949
|
+
image: "gvenzl/oracle-xe:latest",
|
|
950
|
+
port: "1521:1521",
|
|
951
|
+
env: (secrets) => ({
|
|
952
|
+
ORACLE_PASSWORD: secrets.dbPass,
|
|
953
|
+
APP_USER: "system",
|
|
954
|
+
APP_USER_PASSWORD: secrets.dbPass,
|
|
955
|
+
}),
|
|
956
|
+
volumes: ["./data/oracle:/opt/oracle/oradata"],
|
|
957
|
+
},
|
|
958
|
+
redis: {
|
|
959
|
+
image: "redis:alpine",
|
|
960
|
+
port: "6379:6379",
|
|
961
|
+
command: null, // set dynamically if password
|
|
962
|
+
env: () => ({}),
|
|
963
|
+
volumes: ["./data/redis:/data"],
|
|
964
|
+
},
|
|
965
|
+
dynamodb: {
|
|
966
|
+
image: "amazon/dynamodb-local:latest",
|
|
967
|
+
port: "8000:8000",
|
|
968
|
+
env: () => ({}),
|
|
969
|
+
volumes: [],
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* CloudBeaver JDBC driver IDs and URL templates per database.
|
|
975
|
+
*/
|
|
976
|
+
const CLOUDBEAVER_DB_MAP = {
|
|
977
|
+
mysql: {
|
|
978
|
+
provider: "mysql",
|
|
979
|
+
driver: "mysql8",
|
|
980
|
+
urlTemplate: (host, port, dbName) =>
|
|
981
|
+
`jdbc:mysql://${host}:${port}/${dbName}`,
|
|
982
|
+
},
|
|
983
|
+
mariadb: {
|
|
984
|
+
provider: "mysql",
|
|
985
|
+
driver: "mariaDB",
|
|
986
|
+
urlTemplate: (host, port, dbName) =>
|
|
987
|
+
`jdbc:mariadb://${host}:${port}/${dbName}`,
|
|
988
|
+
},
|
|
989
|
+
postgres: {
|
|
990
|
+
provider: "postgresql",
|
|
991
|
+
driver: "postgres-jdbc",
|
|
992
|
+
urlTemplate: (host, port, dbName) =>
|
|
993
|
+
`jdbc:postgresql://${host}:${port}/${dbName}`,
|
|
994
|
+
},
|
|
995
|
+
cockroachdb: {
|
|
996
|
+
provider: "postgresql",
|
|
997
|
+
driver: "postgres-jdbc",
|
|
998
|
+
urlTemplate: (host, port, dbName) =>
|
|
999
|
+
`jdbc:postgresql://${host}:${port}/${dbName}`,
|
|
1000
|
+
},
|
|
1001
|
+
mssql: {
|
|
1002
|
+
provider: "sqlserver",
|
|
1003
|
+
driver: "mssql_jdbc_ms_new",
|
|
1004
|
+
urlTemplate: (host, port, dbName) =>
|
|
1005
|
+
`jdbc:sqlserver://${host}:${port};databaseName=${dbName};trustServerCertificate=true`,
|
|
1006
|
+
},
|
|
1007
|
+
oracle: {
|
|
1008
|
+
provider: "oracle",
|
|
1009
|
+
driver: "oracle_thin",
|
|
1010
|
+
urlTemplate: (host, port, dbName) =>
|
|
1011
|
+
`jdbc:oracle:thin:@${host}:${port}/${dbName}`,
|
|
1012
|
+
},
|
|
1013
|
+
mongodb: {
|
|
1014
|
+
provider: "mongodb",
|
|
1015
|
+
driver: "mongodb",
|
|
1016
|
+
urlTemplate: (host, port, dbName) => `mongodb://${host}:${port}/${dbName}`,
|
|
1017
|
+
},
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Generate CloudBeaver data-sources.json for auto-connecting to the project database.
|
|
1022
|
+
* @param {import('./types').InitAnswers} answers
|
|
1023
|
+
* @param {object} secrets
|
|
1024
|
+
* @returns {string|null}
|
|
1025
|
+
*/
|
|
1026
|
+
function generateCloudBeaverDataSources(answers, secrets) {
|
|
1027
|
+
const cbDb = CLOUDBEAVER_DB_MAP[answers.database];
|
|
1028
|
+
if (!cbDb) return null;
|
|
1029
|
+
|
|
1030
|
+
const dbConfig = DOCKER_DB_MAP[answers.database];
|
|
1031
|
+
if (!dbConfig) return null;
|
|
1032
|
+
|
|
1033
|
+
const host = answers.database; // service name in docker-compose
|
|
1034
|
+
const port = dbConfig.port.split(":")[1];
|
|
1035
|
+
const dbName = "my_app";
|
|
1036
|
+
|
|
1037
|
+
// Determine user/pass based on adapter
|
|
1038
|
+
let user = "root";
|
|
1039
|
+
let pass = secrets.dbPass;
|
|
1040
|
+
if (answers.database === "postgres" || answers.database === "cockroachdb")
|
|
1041
|
+
user = "postgres";
|
|
1042
|
+
if (answers.database === "mssql") user = "sa";
|
|
1043
|
+
if (answers.database === "oracle") user = "system";
|
|
1044
|
+
if (answers.database === "mongodb") user = "root";
|
|
1045
|
+
|
|
1046
|
+
const connId = `${answers.database}-project-db`;
|
|
1047
|
+
const url = cbDb.urlTemplate(host, port, dbName);
|
|
1048
|
+
|
|
1049
|
+
const config = {
|
|
1050
|
+
folders: {},
|
|
1051
|
+
connections: {
|
|
1052
|
+
[connId]: {
|
|
1053
|
+
provider: cbDb.provider,
|
|
1054
|
+
driver: cbDb.driver,
|
|
1055
|
+
name: `${answers.database} - my_app`,
|
|
1056
|
+
"save-password": true,
|
|
1057
|
+
configuration: {
|
|
1058
|
+
host: host,
|
|
1059
|
+
port: port,
|
|
1060
|
+
database: dbName,
|
|
1061
|
+
url: url,
|
|
1062
|
+
configurationType: "MANUAL",
|
|
1063
|
+
type: "dev",
|
|
1064
|
+
auth: "native",
|
|
1065
|
+
userName: user,
|
|
1066
|
+
userPassword: pass,
|
|
1067
|
+
},
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
return JSON.stringify(config, null, 2) + "\n";
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Generate docker-compose.yml content.
|
|
1077
|
+
* @param {import('./types').InitAnswers} answers
|
|
1078
|
+
* @param {object} secrets - { dbPass, redisPass }
|
|
1079
|
+
* @returns {string|null} null if no Docker service needed (e.g. sqlite3)
|
|
1080
|
+
*/
|
|
1081
|
+
function generateDockerCompose(answers, secrets) {
|
|
1082
|
+
// sqlite3 runs in-process, no Docker needed
|
|
1083
|
+
if (answers.database === "sqlite3") return null;
|
|
1084
|
+
|
|
1085
|
+
const dbConfig = DOCKER_DB_MAP[answers.database];
|
|
1086
|
+
if (!dbConfig) return null;
|
|
1087
|
+
|
|
1088
|
+
const services = {};
|
|
1089
|
+
|
|
1090
|
+
// --- Primary database service ---
|
|
1091
|
+
const dbService = {
|
|
1092
|
+
container_name: `${answers.database}_db`,
|
|
1093
|
+
image: dbConfig.image,
|
|
1094
|
+
ports: [dbConfig.port],
|
|
1095
|
+
restart: "unless-stopped",
|
|
1096
|
+
};
|
|
1097
|
+
|
|
1098
|
+
const envVars = dbConfig.env(secrets);
|
|
1099
|
+
if (Object.keys(envVars).length > 0) {
|
|
1100
|
+
dbService.environment = envVars;
|
|
1101
|
+
}
|
|
1102
|
+
if (dbConfig.command) {
|
|
1103
|
+
dbService.command = dbConfig.command;
|
|
1104
|
+
}
|
|
1105
|
+
// Redis with password
|
|
1106
|
+
if (answers.database === "redis" && secrets.dbPass) {
|
|
1107
|
+
dbService.command = `redis-server --requirepass ${secrets.dbPass}`;
|
|
1108
|
+
}
|
|
1109
|
+
if (dbConfig.volumes && dbConfig.volumes.length > 0) {
|
|
1110
|
+
dbService.volumes = dbConfig.volumes;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
services[answers.database] = dbService;
|
|
1114
|
+
|
|
1115
|
+
// --- Redis session service (if session=redis and db is not already redis) ---
|
|
1116
|
+
if (answers.session === "redis" && answers.database !== "redis") {
|
|
1117
|
+
const redisService = {
|
|
1118
|
+
container_name: "redis_session",
|
|
1119
|
+
image: "redis:alpine",
|
|
1120
|
+
ports: ["6379:6379"],
|
|
1121
|
+
restart: "unless-stopped",
|
|
1122
|
+
};
|
|
1123
|
+
if (secrets.redisPass) {
|
|
1124
|
+
redisService.command = `redis-server --requirepass ${secrets.redisPass}`;
|
|
1125
|
+
}
|
|
1126
|
+
redisService.volumes = ["./data/redis:/data"];
|
|
1127
|
+
services["redis"] = redisService;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// --- CloudBeaver service (for SQL/MongoDB databases) ---
|
|
1131
|
+
const hasCbSupport = !!CLOUDBEAVER_DB_MAP[answers.database];
|
|
1132
|
+
if (hasCbSupport) {
|
|
1133
|
+
services["cloudbeaver"] = {
|
|
1134
|
+
container_name: "cloudbeaver",
|
|
1135
|
+
image: "dbeaver/cloudbeaver:latest",
|
|
1136
|
+
ports: ["8978:8978"],
|
|
1137
|
+
restart: "unless-stopped",
|
|
1138
|
+
environment: {
|
|
1139
|
+
CB_SERVER_NAME: "CloudBeaver",
|
|
1140
|
+
CB_ADMIN_NAME: "cbadmin",
|
|
1141
|
+
CB_ADMIN_PASSWORD: secrets.dbPass,
|
|
1142
|
+
},
|
|
1143
|
+
volumes: [
|
|
1144
|
+
"./data/cloudbeaver:/opt/cloudbeaver/workspace",
|
|
1145
|
+
"./.cloudbeaver/data-sources.json:/opt/cloudbeaver/workspace/GlobalConfiguration/.dbeaver/data-sources.json:ro",
|
|
1146
|
+
],
|
|
1147
|
+
depends_on: [answers.database],
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// --- Loki + Grafana (when logger + loki are enabled) ---
|
|
1152
|
+
if (answers.loki) {
|
|
1153
|
+
services["loki"] = {
|
|
1154
|
+
container_name: "loki",
|
|
1155
|
+
image: "grafana/loki:latest",
|
|
1156
|
+
ports: ["3100:3100"],
|
|
1157
|
+
restart: "unless-stopped",
|
|
1158
|
+
command: "-config.file=/etc/loki/local-config.yaml",
|
|
1159
|
+
volumes: ["./data/loki:/loki"],
|
|
1160
|
+
};
|
|
1161
|
+
|
|
1162
|
+
services["grafana"] = {
|
|
1163
|
+
container_name: "grafana",
|
|
1164
|
+
image: "grafana/grafana:latest",
|
|
1165
|
+
ports: ["3001:3000"],
|
|
1166
|
+
restart: "unless-stopped",
|
|
1167
|
+
environment: {
|
|
1168
|
+
GF_SECURITY_ADMIN_USER: "admin",
|
|
1169
|
+
GF_SECURITY_ADMIN_PASSWORD: secrets.dbPass,
|
|
1170
|
+
GF_AUTH_ANONYMOUS_ENABLED: "true",
|
|
1171
|
+
},
|
|
1172
|
+
volumes: [
|
|
1173
|
+
"./data/grafana:/var/lib/grafana",
|
|
1174
|
+
"./.grafana/datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml:ro",
|
|
1175
|
+
],
|
|
1176
|
+
depends_on: ["loki"],
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// --- Build YAML manually (no dependency needed) ---
|
|
1181
|
+
const lines = [];
|
|
1182
|
+
lines.push("services:");
|
|
1183
|
+
|
|
1184
|
+
for (const [name, svc] of Object.entries(services)) {
|
|
1185
|
+
lines.push(` ${name}:`);
|
|
1186
|
+
lines.push(` container_name: ${svc.container_name}`);
|
|
1187
|
+
lines.push(` image: ${svc.image}`);
|
|
1188
|
+
if (svc.command) {
|
|
1189
|
+
lines.push(` command: ${svc.command}`);
|
|
1190
|
+
}
|
|
1191
|
+
if (svc.ports && svc.ports.length > 0) {
|
|
1192
|
+
lines.push(" ports:");
|
|
1193
|
+
for (const p of svc.ports) {
|
|
1194
|
+
lines.push(` - "${p}"`);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (svc.environment && Object.keys(svc.environment).length > 0) {
|
|
1198
|
+
lines.push(" environment:");
|
|
1199
|
+
for (const [k, v] of Object.entries(svc.environment)) {
|
|
1200
|
+
lines.push(` ${k}: "${v}"`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (svc.volumes && svc.volumes.length > 0) {
|
|
1204
|
+
lines.push(" volumes:");
|
|
1205
|
+
for (const v of svc.volumes) {
|
|
1206
|
+
lines.push(` - ${v}`);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (svc.depends_on && svc.depends_on.length > 0) {
|
|
1210
|
+
lines.push(" depends_on:");
|
|
1211
|
+
for (const d of svc.depends_on) {
|
|
1212
|
+
lines.push(` - ${d}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
lines.push(` restart: unless-stopped`);
|
|
1216
|
+
lines.push("");
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
return lines.join("\n");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Generate Dockerfile for the project.
|
|
1224
|
+
* Uses multi-stage build with node:alpine for a lean production image.
|
|
1225
|
+
* @param {import('./types').InitAnswers} answers
|
|
1226
|
+
* @param {string} [outputDir] - relative output directory for source files
|
|
1227
|
+
* @returns {string}
|
|
1228
|
+
*/
|
|
1229
|
+
function generateDockerfile(answers, outputDir) {
|
|
1230
|
+
const copyDirs = ["commons", "middleware", "route", "migrations"]
|
|
1231
|
+
.map((d) => {
|
|
1232
|
+
const src = outputDir ? `${outputDir}/${d}` : d;
|
|
1233
|
+
return `COPY ${src}/ ./${src}/`;
|
|
1234
|
+
})
|
|
1235
|
+
.join("\n");
|
|
1236
|
+
|
|
1237
|
+
return `FROM node:alpine
|
|
1238
|
+
|
|
1239
|
+
WORKDIR /app
|
|
1240
|
+
|
|
1241
|
+
# Install dependencies
|
|
1242
|
+
COPY package*.json ./
|
|
1243
|
+
RUN npm ci --omit=dev
|
|
1244
|
+
|
|
1245
|
+
# Copy application files
|
|
1246
|
+
COPY app.js ./
|
|
1247
|
+
${copyDirs}
|
|
1248
|
+
|
|
1249
|
+
# Expose port
|
|
1250
|
+
EXPOSE 3000
|
|
1251
|
+
|
|
1252
|
+
# Start the application
|
|
1253
|
+
CMD ["node", "app.js"]
|
|
1254
|
+
`;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Generate Grafana datasource provisioning file for auto-connecting Loki.
|
|
1259
|
+
* @returns {string}
|
|
1260
|
+
*/
|
|
1261
|
+
function generateGrafanaDatasources() {
|
|
1262
|
+
return `apiVersion: 1
|
|
1263
|
+
|
|
1264
|
+
datasources:
|
|
1265
|
+
- name: Loki
|
|
1266
|
+
type: loki
|
|
1267
|
+
access: proxy
|
|
1268
|
+
url: http://loki:3100
|
|
1269
|
+
isDefault: true
|
|
1270
|
+
editable: false
|
|
1271
|
+
`;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Generate .dockerignore content.
|
|
1276
|
+
* @returns {string}
|
|
1277
|
+
*/
|
|
1278
|
+
function generateDockerignore() {
|
|
1279
|
+
return `node_modules
|
|
1280
|
+
npm-debug.log
|
|
1281
|
+
.env
|
|
1282
|
+
.env.example
|
|
1283
|
+
.git
|
|
1284
|
+
.gitignore
|
|
1285
|
+
data
|
|
1286
|
+
`;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
757
1289
|
/**
|
|
758
1290
|
* Generate .gitignore content.
|
|
759
1291
|
* @returns {string}
|
|
@@ -762,13 +1294,478 @@ function generateGitignore() {
|
|
|
762
1294
|
return `node_modules/
|
|
763
1295
|
.env
|
|
764
1296
|
*.db
|
|
1297
|
+
data/
|
|
1298
|
+
.cloudbeaver/
|
|
1299
|
+
`;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ---------------------------------------------------------------------------
|
|
1303
|
+
// Commons: session.js generator
|
|
1304
|
+
// ---------------------------------------------------------------------------
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Generate commons/session.js — session configuration module.
|
|
1308
|
+
* @param {import('./types').InitAnswers} answers
|
|
1309
|
+
* @returns {string}
|
|
1310
|
+
*/
|
|
1311
|
+
function generateSessionJs(answers) {
|
|
1312
|
+
let imports = `import session from "express-session";\n`;
|
|
1313
|
+
|
|
1314
|
+
if (answers.session === "redis") {
|
|
1315
|
+
imports += `import { RedisStore } from "connect-redis";\nimport ioredis from "ioredis";\n\nconst { Redis } = ioredis;\n`;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
let storeSetup = "";
|
|
1319
|
+
let storeOption = "";
|
|
1320
|
+
|
|
1321
|
+
if (answers.session === "redis") {
|
|
1322
|
+
const redisConfig =
|
|
1323
|
+
answers.database === "redis"
|
|
1324
|
+
? ` host: process.env.DB_HOST || "localhost",\n port: process.env.DB_PORT || 6379,\n password: process.env.DB_PASS,`
|
|
1325
|
+
: ` host: process.env.REDIS_HOST || "localhost",\n port: process.env.REDIS_PORT || 6379,\n password: process.env.REDIS_PASS,`;
|
|
1326
|
+
|
|
1327
|
+
storeSetup = `\nconst redisClient = new Redis({\n${redisConfig}\n});\n`;
|
|
1328
|
+
storeOption = `\n store: new RedisStore({ client: redisClient }),`;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
return `${imports}${storeSetup}
|
|
1332
|
+
/**
|
|
1333
|
+
* Configure and return session middleware.
|
|
1334
|
+
* Session store: ${answers.session}
|
|
1335
|
+
*/
|
|
1336
|
+
export default function configureSession() {
|
|
1337
|
+
return session({${storeOption}
|
|
1338
|
+
secret: process.env.SESSION_SECRET || "change-me",
|
|
1339
|
+
resave: false,
|
|
1340
|
+
saveUninitialized: false,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
`;
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// ---------------------------------------------------------------------------
|
|
1347
|
+
// Commons: migrate.js generator (standalone script)
|
|
1348
|
+
// ---------------------------------------------------------------------------
|
|
1349
|
+
|
|
1350
|
+
/**
|
|
1351
|
+
* Generate commons/migrate.js — migration runner module.
|
|
1352
|
+
* Works as both an importable module and a standalone script.
|
|
1353
|
+
* @param {import('./types').InitAnswers} answers
|
|
1354
|
+
* @param {string} [outputDir] - relative output directory
|
|
1355
|
+
* @returns {string}
|
|
1356
|
+
*/
|
|
1357
|
+
function generateMigrateModule(answers, outputDir) {
|
|
1358
|
+
const isNoSql = NOSQL_DATABASES.includes(answers.database);
|
|
1359
|
+
// commons/migrate.js and migrations/ are sibling dirs inside the same outputDir
|
|
1360
|
+
const migrationsRel = "../migrations";
|
|
1361
|
+
|
|
1362
|
+
if (isNoSql) {
|
|
1363
|
+
return `#!/usr/bin/env node
|
|
1364
|
+
import fs from "fs";
|
|
1365
|
+
import path from "path";
|
|
1366
|
+
import crypto from "crypto";
|
|
1367
|
+
import { fileURLToPath } from "url";
|
|
1368
|
+
|
|
1369
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Run all pending migrations from the migrations directory.
|
|
1373
|
+
* @param {object} db - db-model-router db instance
|
|
1374
|
+
* @param {string} migrationsDir - absolute path to migrations folder
|
|
1375
|
+
*/
|
|
1376
|
+
export default async function runMigrations(db, migrationsDir) {
|
|
1377
|
+
const files = fs.readdirSync(migrationsDir)
|
|
1378
|
+
.filter(f => f.endsWith(".js"))
|
|
1379
|
+
.sort();
|
|
1380
|
+
|
|
1381
|
+
let executed;
|
|
1382
|
+
try {
|
|
1383
|
+
const result = await db.get("_migrations");
|
|
1384
|
+
executed = new Set((result || []).map(r => r.filename));
|
|
1385
|
+
} catch (e) {
|
|
1386
|
+
executed = new Set();
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
let ran = 0;
|
|
1390
|
+
for (const file of files) {
|
|
1391
|
+
if (executed.has(file)) {
|
|
1392
|
+
console.log(\` Skipping (already executed): \${file}\`);
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
const filePath = path.join(migrationsDir, file);
|
|
1396
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
1397
|
+
const checksum = crypto.createHash("md5").update(content).digest("hex");
|
|
1398
|
+
|
|
1399
|
+
const migration = await import(filePath);
|
|
1400
|
+
console.log(\` Running migration: \${file}\`);
|
|
1401
|
+
await migration.up(db);
|
|
1402
|
+
await db.insert("_migrations", {
|
|
1403
|
+
filename: file,
|
|
1404
|
+
executed_at: new Date().toISOString(),
|
|
1405
|
+
checksum,
|
|
1406
|
+
});
|
|
1407
|
+
console.log(\` Completed: \${file}\`);
|
|
1408
|
+
ran++;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
if (ran === 0) {
|
|
1412
|
+
console.log("No pending migrations.");
|
|
1413
|
+
} else {
|
|
1414
|
+
console.log(\`\\n\${ran} migration(s) complete.\`);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Run as standalone script
|
|
1419
|
+
const isMain = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1420
|
+
if (isMain) {
|
|
1421
|
+
await import("dotenv/config");
|
|
1422
|
+
const pkg = await import("db-model-router");
|
|
1423
|
+
const mod = pkg.default || pkg;
|
|
1424
|
+
mod.init("${answers.database}");
|
|
1425
|
+
const migrationsDir = path.join(__dirname, "${migrationsRel}");
|
|
1426
|
+
runMigrations(mod.db, migrationsDir)
|
|
1427
|
+
.then(() => process.exit(0))
|
|
1428
|
+
.catch(err => { console.error("Migration failed:", err); process.exit(1); });
|
|
1429
|
+
}
|
|
1430
|
+
`;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
return `#!/usr/bin/env node
|
|
1434
|
+
import fs from "fs";
|
|
1435
|
+
import path from "path";
|
|
1436
|
+
import crypto from "crypto";
|
|
1437
|
+
import { fileURLToPath } from "url";
|
|
1438
|
+
|
|
1439
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Run all pending SQL migrations from the migrations directory.
|
|
1443
|
+
* @param {object} db - db-model-router db instance
|
|
1444
|
+
* @param {string} migrationsDir - absolute path to migrations folder
|
|
1445
|
+
*/
|
|
1446
|
+
export default async function runMigrations(db, migrationsDir) {
|
|
1447
|
+
const files = fs.readdirSync(migrationsDir)
|
|
1448
|
+
.filter(f => f.endsWith(".sql"))
|
|
1449
|
+
.sort();
|
|
1450
|
+
|
|
1451
|
+
let executed;
|
|
1452
|
+
try {
|
|
1453
|
+
const result = await db.query("SELECT filename FROM _migrations");
|
|
1454
|
+
executed = new Set((result || []).map(r => r.filename));
|
|
1455
|
+
} catch (e) {
|
|
1456
|
+
executed = new Set();
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
let ran = 0;
|
|
1460
|
+
for (const file of files) {
|
|
1461
|
+
if (executed.has(file)) {
|
|
1462
|
+
console.log(\` Skipping (already executed): \${file}\`);
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
const filePath = path.join(migrationsDir, file);
|
|
1466
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
1467
|
+
const checksum = crypto.createHash("md5").update(content).digest("hex");
|
|
1468
|
+
|
|
1469
|
+
console.log(\` Running migration: \${file}\`);
|
|
1470
|
+
await db.query(content);
|
|
1471
|
+
await db.query(
|
|
1472
|
+
"INSERT INTO _migrations (filename, checksum) VALUES (?, ?)",
|
|
1473
|
+
[file, checksum]
|
|
1474
|
+
);
|
|
1475
|
+
console.log(\` Completed: \${file}\`);
|
|
1476
|
+
ran++;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if (ran === 0) {
|
|
1480
|
+
console.log("No pending migrations.");
|
|
1481
|
+
} else {
|
|
1482
|
+
console.log(\`\\n\${ran} migration(s) complete.\`);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Run as standalone script
|
|
1487
|
+
const isMain = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1488
|
+
if (isMain) {
|
|
1489
|
+
await import("dotenv/config");
|
|
1490
|
+
const pkg = await import("db-model-router");
|
|
1491
|
+
const mod = pkg.default || pkg;
|
|
1492
|
+
mod.init("${answers.database}");
|
|
1493
|
+
const migrationsDir = path.join(__dirname, "${migrationsRel}");
|
|
1494
|
+
runMigrations(mod.db, migrationsDir)
|
|
1495
|
+
.then(() => process.exit(0))
|
|
1496
|
+
.catch(err => { console.error("Migration failed:", err); process.exit(1); });
|
|
1497
|
+
}
|
|
1498
|
+
`;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// ---------------------------------------------------------------------------
|
|
1502
|
+
// Commons: add_migration.js generator (standalone script)
|
|
1503
|
+
// ---------------------------------------------------------------------------
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Generate commons/add_migration.js — migration creation helper module.
|
|
1507
|
+
* Works as both an importable module and a standalone script.
|
|
1508
|
+
* @param {import('./types').InitAnswers} answers
|
|
1509
|
+
* @param {string} [outputDir] - relative output directory
|
|
1510
|
+
* @returns {string}
|
|
1511
|
+
*/
|
|
1512
|
+
function generateAddMigrationModule(answers, outputDir) {
|
|
1513
|
+
const isNoSql = NOSQL_DATABASES.includes(answers.database);
|
|
1514
|
+
const ext = isNoSql ? "js" : "sql";
|
|
1515
|
+
const template = isNoSql
|
|
1516
|
+
? `export async function up(db) {\\n // Write your migration here\\n}\\n\\nexport async function down(db) {\\n // Write your rollback here\\n}\\n`
|
|
1517
|
+
: `-- Write your migration SQL here\\n`;
|
|
1518
|
+
const migrationsRel = "../migrations";
|
|
1519
|
+
|
|
1520
|
+
return `#!/usr/bin/env node
|
|
1521
|
+
import fs from "fs";
|
|
1522
|
+
import path from "path";
|
|
1523
|
+
import { fileURLToPath } from "url";
|
|
1524
|
+
|
|
1525
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
1526
|
+
|
|
1527
|
+
/**
|
|
1528
|
+
* Create a new timestamped migration file.
|
|
1529
|
+
* @param {string} migrationsDir - absolute path to migrations folder
|
|
1530
|
+
* @param {string} [name] - migration name (default: "migration")
|
|
1531
|
+
* @returns {string} the created filename
|
|
1532
|
+
*/
|
|
1533
|
+
export default function addMigration(migrationsDir, name) {
|
|
1534
|
+
const migrationName = name || "migration";
|
|
1535
|
+
const now = new Date();
|
|
1536
|
+
const y = String(now.getFullYear()).padStart(4, "0");
|
|
1537
|
+
const mo = String(now.getMonth() + 1).padStart(2, "0");
|
|
1538
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
1539
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
1540
|
+
const mi = String(now.getMinutes()).padStart(2, "0");
|
|
1541
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
1542
|
+
const ts = \`\${y}\${mo}\${d}\${h}\${mi}\${s}\`;
|
|
1543
|
+
|
|
1544
|
+
const filename = \`\${ts}_\${migrationName}.${ext}\`;
|
|
1545
|
+
const filePath = path.join(migrationsDir, filename);
|
|
1546
|
+
|
|
1547
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
1548
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
fs.writeFileSync(filePath, "${template}");
|
|
1552
|
+
console.log(\`Created migration: \${filename}\`);
|
|
1553
|
+
return filename;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// Run as standalone script
|
|
1557
|
+
const isMain = process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
1558
|
+
if (isMain) {
|
|
1559
|
+
const migrationsDir = path.join(__dirname, "${migrationsRel}");
|
|
1560
|
+
const name = process.argv[2] || "migration";
|
|
1561
|
+
addMigration(migrationsDir, name);
|
|
1562
|
+
}
|
|
1563
|
+
`;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// ---------------------------------------------------------------------------
|
|
1567
|
+
// Commons: security.js generator (helmet + header overrides)
|
|
1568
|
+
// ---------------------------------------------------------------------------
|
|
1569
|
+
|
|
1570
|
+
/**
|
|
1571
|
+
* Generate commons/security.js — helmet and custom header security middleware.
|
|
1572
|
+
* @param {import('./types').InitAnswers} answers
|
|
1573
|
+
* @returns {string}
|
|
1574
|
+
*/
|
|
1575
|
+
function generateSecurityJs(answers) {
|
|
1576
|
+
let imports = "";
|
|
1577
|
+
if (answers.helmet) {
|
|
1578
|
+
imports += `import helmet from "helmet";\n`;
|
|
1579
|
+
}
|
|
1580
|
+
if (answers.rateLimiting) {
|
|
1581
|
+
imports += `import rateLimit from "express-rate-limit";\n`;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
return `${imports}
|
|
1585
|
+
/**
|
|
1586
|
+
* Apply security middleware to the Express app.
|
|
1587
|
+
* Includes: ${answers.helmet ? "Helmet, " : ""}${answers.rateLimiting ? "rate limiting, " : ""}custom security headers.
|
|
1588
|
+
* @param {import("express").Application} app
|
|
1589
|
+
*/
|
|
1590
|
+
export default function applySecurity(app) {
|
|
1591
|
+
${answers.helmet ? ` // Helmet — sets various HTTP headers for security\n app.use(helmet());\n` : " // Helmet is not enabled. Install and enable via --helmet flag.\n"}
|
|
1592
|
+
${answers.rateLimiting ? ` // Rate limiting\n app.use(rateLimit({\n windowMs: 15 * 60 * 1000,\n max: 100,\n standardHeaders: true,\n legacyHeaders: false,\n }));\n` : ""}
|
|
1593
|
+
// Custom security headers (override or extend as needed)
|
|
1594
|
+
app.use((req, res, next) => {
|
|
1595
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1596
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
1597
|
+
res.setHeader("X-XSS-Protection", "1; mode=block");
|
|
1598
|
+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1599
|
+
res.removeHeader("X-Powered-By");
|
|
1600
|
+
next();
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
`;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// ---------------------------------------------------------------------------
|
|
1607
|
+
// Route: health.js generator
|
|
1608
|
+
// ---------------------------------------------------------------------------
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* Generate route/health.js — health check route.
|
|
1612
|
+
* @returns {string}
|
|
1613
|
+
*/
|
|
1614
|
+
function generateHealthRoute() {
|
|
1615
|
+
return `import express from "express";
|
|
1616
|
+
|
|
1617
|
+
const router = express.Router();
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* GET /health
|
|
1621
|
+
* Returns server health status, uptime, memory, and database connectivity.
|
|
1622
|
+
*/
|
|
1623
|
+
router.get("/", async (req, res) => {
|
|
1624
|
+
const health = {
|
|
1625
|
+
status: "ok",
|
|
1626
|
+
timestamp: new Date().toISOString(),
|
|
1627
|
+
uptime: process.uptime(),
|
|
1628
|
+
memory: process.memoryUsage(),
|
|
1629
|
+
db: { connected: false },
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
try {
|
|
1633
|
+
if (global.db && typeof global.db.query === "function") {
|
|
1634
|
+
await global.db.query("SELECT NOW()");
|
|
1635
|
+
health.db.connected = true;
|
|
1636
|
+
} else if (global.db && typeof global.db.get === "function") {
|
|
1637
|
+
// NoSQL adapters (mongodb, redis, dynamodb)
|
|
1638
|
+
health.db.connected = true;
|
|
1639
|
+
}
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
health.status = "degraded";
|
|
1642
|
+
health.db.error = err.message;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const statusCode = health.status === "ok" ? 200 : 503;
|
|
1646
|
+
res.status(statusCode).json(health);
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
export default router;
|
|
1650
|
+
`;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// ---------------------------------------------------------------------------
|
|
1654
|
+
// Route: index.js generator — mounts all route modules
|
|
1655
|
+
// ---------------------------------------------------------------------------
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* Generate route/index.js — central route mounting file.
|
|
1659
|
+
* @returns {string}
|
|
1660
|
+
*/
|
|
1661
|
+
function generateRouteIndexFile() {
|
|
1662
|
+
return `import express from "express";
|
|
1663
|
+
import healthRoute from "./health.js";
|
|
1664
|
+
|
|
1665
|
+
const router = express.Router();
|
|
1666
|
+
|
|
1667
|
+
router.use("/health", healthRoute);
|
|
1668
|
+
|
|
1669
|
+
export default router;
|
|
1670
|
+
`;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
// ---------------------------------------------------------------------------
|
|
1674
|
+
// Commons: db.js generator — database init, connect, and global.db
|
|
1675
|
+
// ---------------------------------------------------------------------------
|
|
1676
|
+
|
|
1677
|
+
/**
|
|
1678
|
+
* Generate commons/db.js — database initialization and connection module.
|
|
1679
|
+
* Sets global.db so the db instance is accessible across the application.
|
|
1680
|
+
* @param {import('./types').InitAnswers} answers
|
|
1681
|
+
* @returns {string}
|
|
1682
|
+
*/
|
|
1683
|
+
function generateDbModule(answers) {
|
|
1684
|
+
return `import "dotenv/config";
|
|
1685
|
+
import dbModelRouter from "db-model-router";
|
|
1686
|
+
|
|
1687
|
+
// Initialize database adapter
|
|
1688
|
+
dbModelRouter.init("${answers.database}");
|
|
1689
|
+
|
|
1690
|
+
// Connect to database
|
|
1691
|
+
dbModelRouter.db.connect({
|
|
1692
|
+
${dbConnectArgs(answers.database)}});
|
|
1693
|
+
|
|
1694
|
+
// Make db available globally across the application
|
|
1695
|
+
const db = dbModelRouter.db;
|
|
1696
|
+
global.db = db;
|
|
1697
|
+
|
|
1698
|
+
export { db };
|
|
1699
|
+
export default db;
|
|
1700
|
+
`;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// ---------------------------------------------------------------------------
|
|
1704
|
+
// Updated app.js generator — links commons and route/index
|
|
1705
|
+
// ---------------------------------------------------------------------------
|
|
1706
|
+
|
|
1707
|
+
/**
|
|
1708
|
+
* Generate the app.js file content (v2 — uses commons modules and route/index).
|
|
1709
|
+
* @param {import('./types').InitAnswers} answers
|
|
1710
|
+
* @param {string} [outputDir] - relative output directory for source files (e.g. "backend")
|
|
1711
|
+
* @returns {string}
|
|
1712
|
+
*/
|
|
1713
|
+
function generateAppJsV2(answers, outputDir) {
|
|
1714
|
+
const frameworkPkg =
|
|
1715
|
+
answers.framework === "ultimate-express" ? "ultimate-express" : "express";
|
|
1716
|
+
|
|
1717
|
+
const commonsPrefix = outputDir ? `./${outputDir}/commons` : "./commons";
|
|
1718
|
+
const routePrefix = outputDir ? `./${outputDir}/routes` : "./routes";
|
|
1719
|
+
const middlewarePrefix = outputDir
|
|
1720
|
+
? `./${outputDir}/middleware`
|
|
1721
|
+
: "./middleware";
|
|
1722
|
+
|
|
1723
|
+
return `import express from "${frameworkPkg}";
|
|
1724
|
+
import "${commonsPrefix}/db.js";
|
|
1725
|
+
import configureSession from "${commonsPrefix}/session.js";
|
|
1726
|
+
import applySecurity from "${commonsPrefix}/security.js";
|
|
1727
|
+
import logger from "${middlewarePrefix}/logger.js";
|
|
1728
|
+
import route from "${routePrefix}/index.js";
|
|
1729
|
+
|
|
1730
|
+
const app = express();
|
|
1731
|
+
const PORT = process.env.PORT || 3000;
|
|
1732
|
+
|
|
1733
|
+
// Middleware
|
|
1734
|
+
app.use(express.json());
|
|
1735
|
+
app.use(express.urlencoded({ extended: true }));
|
|
1736
|
+
|
|
1737
|
+
// Security (helmet, rate limiting, custom headers)
|
|
1738
|
+
applySecurity(app);
|
|
1739
|
+
|
|
1740
|
+
// Session
|
|
1741
|
+
app.use(configureSession());
|
|
1742
|
+
|
|
1743
|
+
// Logger
|
|
1744
|
+
app.use(logger);
|
|
1745
|
+
|
|
1746
|
+
// Routes
|
|
1747
|
+
app.use(route);
|
|
1748
|
+
|
|
1749
|
+
// Error handler
|
|
1750
|
+
app.use((err, req, res, next) => {
|
|
1751
|
+
console.error(err.stack);
|
|
1752
|
+
res.status(500).json({ type: "danger", message: "Internal Server Error" });
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
app.listen(PORT, () => {
|
|
1756
|
+
console.log(\`Server running on port \${PORT}\`);
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
export default app;
|
|
765
1760
|
`;
|
|
766
1761
|
}
|
|
767
1762
|
|
|
768
1763
|
module.exports = {
|
|
769
1764
|
migrationTimestamp,
|
|
770
1765
|
isSql,
|
|
1766
|
+
randomPassword,
|
|
771
1767
|
generateAppJs,
|
|
1768
|
+
generateAppJsV2,
|
|
772
1769
|
generateEnvFile,
|
|
773
1770
|
generateEnvExample,
|
|
774
1771
|
generateLoggerMiddleware,
|
|
@@ -777,6 +1774,18 @@ module.exports = {
|
|
|
777
1774
|
generateInitialMigration,
|
|
778
1775
|
generateSessionMigration,
|
|
779
1776
|
generateGitignore,
|
|
1777
|
+
generateDockerfile,
|
|
1778
|
+
generateDockerignore,
|
|
1779
|
+
generateGrafanaDatasources,
|
|
1780
|
+
generateDockerCompose,
|
|
1781
|
+
generateCloudBeaverDataSources,
|
|
1782
|
+
generateSessionJs,
|
|
1783
|
+
generateMigrateModule,
|
|
1784
|
+
generateAddMigrationModule,
|
|
1785
|
+
generateSecurityJs,
|
|
1786
|
+
generateHealthRoute,
|
|
1787
|
+
generateRouteIndexFile,
|
|
1788
|
+
generateDbModule,
|
|
780
1789
|
SQL_DATABASES,
|
|
781
1790
|
NOSQL_DATABASES,
|
|
782
1791
|
};
|