db-model-router 1.0.2 → 1.0.4
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 +317 -202
- package/docs/SKILL.md +250 -33
- package/docs/adapters/cockroachdb.md +1 -1
- package/docs/adapters/dynamodb.md +1 -1
- package/docs/adapters/mongodb.md +1 -1
- package/docs/adapters/mssql.md +1 -1
- package/docs/adapters/oracle.md +1 -1
- package/docs/adapters/postgres.md +1 -1
- package/docs/adapters/redis.md +1 -1
- package/docs/adapters/sqlite3.md +1 -1
- package/package.json +12 -6
- package/src/cli/commands/diff.js +114 -0
- package/src/cli/commands/doctor.js +181 -0
- package/src/cli/commands/generate-llm-docs.js +418 -0
- package/src/cli/commands/generate.js +240 -0
- package/src/cli/commands/help.js +180 -0
- package/src/cli/commands/init.js +181 -0
- package/src/cli/commands/inspect.js +222 -0
- package/src/cli/diff-engine.js +198 -0
- package/src/cli/flags.js +112 -0
- package/src/cli/generate-model.js +5 -4
- package/src/cli/generate-route.js +255 -14
- package/src/cli/init/dependencies.js +92 -0
- package/src/cli/init/generators.js +1791 -0
- package/src/cli/init/prompt.js +191 -0
- package/src/cli/init.js +404 -0
- package/src/cli/main.js +175 -0
- package/src/commons/model.js +5 -6
- package/src/commons/route.js +24 -0
- package/src/index.js +2 -0
- package/src/schema/schema-parser.js +78 -0
- package/src/schema/schema-printer.js +77 -0
- package/src/schema/schema-to-meta.js +78 -0
- package/src/schema/schema-validator.js +255 -0
- package/src/serve.js +5 -3
- package/docs/README.md +0 -208
- package/src/cli/generate-app.js +0 -359
|
@@ -0,0 +1,1791 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const SQL_DATABASES = [
|
|
4
|
+
"mysql",
|
|
5
|
+
"mariadb",
|
|
6
|
+
"postgres",
|
|
7
|
+
"sqlite3",
|
|
8
|
+
"mssql",
|
|
9
|
+
"cockroachdb",
|
|
10
|
+
"oracle",
|
|
11
|
+
];
|
|
12
|
+
const NOSQL_DATABASES = ["mongodb", "redis", "dynamodb"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Format a Date as YYYYMMDDHHMMSS (14-digit string).
|
|
16
|
+
* @param {Date} date
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function migrationTimestamp(date) {
|
|
20
|
+
const y = String(date.getFullYear()).padStart(4, "0");
|
|
21
|
+
const mo = String(date.getMonth() + 1).padStart(2, "0");
|
|
22
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
23
|
+
const h = String(date.getHours()).padStart(2, "0");
|
|
24
|
+
const mi = String(date.getMinutes()).padStart(2, "0");
|
|
25
|
+
const s = String(date.getSeconds()).padStart(2, "0");
|
|
26
|
+
return `${y}${mo}${d}${h}${mi}${s}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns true if the database is a SQL database.
|
|
31
|
+
* @param {string} database
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
function isSql(database) {
|
|
35
|
+
return SQL_DATABASES.includes(database);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Environment variable config map (DRY: shared by .env and .env.example)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @typedef {Object} EnvVarDef
|
|
44
|
+
* @property {string} key - Variable name
|
|
45
|
+
* @property {string} defaultValue - Value for .env
|
|
46
|
+
* @property {string} placeholder - Value for .env.example
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** @type {Record<string, EnvVarDef[]>} */
|
|
50
|
+
const DB_ENV_MAP = {
|
|
51
|
+
mysql: [
|
|
52
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
53
|
+
{ key: "DB_PORT", defaultValue: "3306", placeholder: "3306" },
|
|
54
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
55
|
+
{ key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
|
|
56
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
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
|
+
],
|
|
65
|
+
postgres: [
|
|
66
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
67
|
+
{ key: "DB_PORT", defaultValue: "5432", placeholder: "5432" },
|
|
68
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
69
|
+
{ key: "DB_USER", defaultValue: "postgres", placeholder: "your_user" },
|
|
70
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
71
|
+
],
|
|
72
|
+
cockroachdb: [
|
|
73
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
74
|
+
{ key: "DB_PORT", defaultValue: "26257", placeholder: "26257" },
|
|
75
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
76
|
+
{ key: "DB_USER", defaultValue: "root", placeholder: "your_user" },
|
|
77
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
78
|
+
],
|
|
79
|
+
sqlite3: [
|
|
80
|
+
{
|
|
81
|
+
key: "DB_NAME",
|
|
82
|
+
defaultValue: "./data/data.db",
|
|
83
|
+
placeholder: "./data/data.db",
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
mongodb: [
|
|
87
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
88
|
+
{ key: "DB_PORT", defaultValue: "27017", placeholder: "27017" },
|
|
89
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
90
|
+
{ key: "DB_USER", defaultValue: "", placeholder: "your_user" },
|
|
91
|
+
{ key: "DB_PASS", defaultValue: "", placeholder: "your_password" },
|
|
92
|
+
],
|
|
93
|
+
mssql: [
|
|
94
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
95
|
+
{ key: "DB_PORT", defaultValue: "1433", placeholder: "1433" },
|
|
96
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
97
|
+
{ key: "DB_USER", defaultValue: "sa", placeholder: "your_user" },
|
|
98
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
99
|
+
],
|
|
100
|
+
oracle: [
|
|
101
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
102
|
+
{ key: "DB_PORT", defaultValue: "1521", placeholder: "1521" },
|
|
103
|
+
{ key: "DB_NAME", defaultValue: "my_app", placeholder: "your_database" },
|
|
104
|
+
{ key: "DB_USER", defaultValue: "system", placeholder: "your_user" },
|
|
105
|
+
{ key: "DB_PASS", defaultValue: "password", placeholder: "your_password" },
|
|
106
|
+
],
|
|
107
|
+
redis: [
|
|
108
|
+
{ key: "DB_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
109
|
+
{ key: "DB_PORT", defaultValue: "6379", placeholder: "6379" },
|
|
110
|
+
{ key: "DB_PASS", defaultValue: "", placeholder: "your_password" },
|
|
111
|
+
],
|
|
112
|
+
dynamodb: [
|
|
113
|
+
{ key: "AWS_REGION", defaultValue: "us-east-1", placeholder: "us-east-1" },
|
|
114
|
+
{
|
|
115
|
+
key: "AWS_ENDPOINT",
|
|
116
|
+
defaultValue: "http://localhost:8000",
|
|
117
|
+
placeholder: "http://localhost:8000",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
key: "AWS_ACCESS_KEY_ID",
|
|
121
|
+
defaultValue: "local",
|
|
122
|
+
placeholder: "your_access_key",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
key: "AWS_SECRET_ACCESS_KEY",
|
|
126
|
+
defaultValue: "local",
|
|
127
|
+
placeholder: "your_secret_key",
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const REDIS_SESSION_VARS = [
|
|
133
|
+
{ key: "REDIS_HOST", defaultValue: "localhost", placeholder: "localhost" },
|
|
134
|
+
{ key: "REDIS_PORT", defaultValue: "6379", placeholder: "6379" },
|
|
135
|
+
{ key: "REDIS_PASS", defaultValue: "", placeholder: "your_password" },
|
|
136
|
+
];
|
|
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
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Build env file content from the config map.
|
|
158
|
+
* @param {import('./types').InitAnswers} answers
|
|
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
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
function buildEnvContent(answers, mode, secrets) {
|
|
167
|
+
const s = secrets || {};
|
|
168
|
+
const pick = mode === "placeholder" ? "placeholder" : "defaultValue";
|
|
169
|
+
const lines = [];
|
|
170
|
+
lines.push("# Server");
|
|
171
|
+
lines.push("PORT=3000");
|
|
172
|
+
lines.push("");
|
|
173
|
+
lines.push("# Database");
|
|
174
|
+
|
|
175
|
+
const vars = DB_ENV_MAP[answers.database] || [];
|
|
176
|
+
for (const v of vars) {
|
|
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
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Session secret
|
|
186
|
+
lines.push("");
|
|
187
|
+
lines.push("# Session");
|
|
188
|
+
lines.push(
|
|
189
|
+
`SESSION_SECRET=${mode === "placeholder" ? "your_session_secret" : s.sessionSecret || "change-me"}`,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Redis session env vars when session is redis and database is not redis
|
|
193
|
+
if (answers.session === "redis" && answers.database !== "redis") {
|
|
194
|
+
lines.push("");
|
|
195
|
+
lines.push("# Redis Session");
|
|
196
|
+
for (const v of REDIS_SESSION_VARS) {
|
|
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
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lines.push("");
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Generate .env file content.
|
|
229
|
+
* @param {import('./types').InitAnswers} answers
|
|
230
|
+
* @param {object} [secrets] - generated secrets
|
|
231
|
+
* @returns {string}
|
|
232
|
+
*/
|
|
233
|
+
function generateEnvFile(answers, secrets) {
|
|
234
|
+
return buildEnvContent(answers, "default", secrets);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Generate .env.example file content with placeholder values.
|
|
239
|
+
* @param {import('./types').InitAnswers} answers
|
|
240
|
+
* @returns {string}
|
|
241
|
+
*/
|
|
242
|
+
function generateEnvExample(answers) {
|
|
243
|
+
return buildEnvContent(answers, "placeholder");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// App.js generator (template-literal based)
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Generate the db.connect() block for the selected database.
|
|
252
|
+
* @param {string} database
|
|
253
|
+
* @returns {string}
|
|
254
|
+
*/
|
|
255
|
+
function dbConnectBlock(database) {
|
|
256
|
+
if (database === "dynamodb") {
|
|
257
|
+
return `db.connect({
|
|
258
|
+
region: process.env.AWS_REGION,
|
|
259
|
+
endpoint: process.env.AWS_ENDPOINT,
|
|
260
|
+
credentials: {
|
|
261
|
+
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
|
|
262
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
263
|
+
},
|
|
264
|
+
});`;
|
|
265
|
+
}
|
|
266
|
+
if (database === "redis") {
|
|
267
|
+
return `db.connect({
|
|
268
|
+
host: process.env.DB_HOST || "localhost",
|
|
269
|
+
port: process.env.DB_PORT || 6379,
|
|
270
|
+
password: process.env.DB_PASS,
|
|
271
|
+
});`;
|
|
272
|
+
}
|
|
273
|
+
if (database === "sqlite3") {
|
|
274
|
+
return `db.connect({
|
|
275
|
+
database: process.env.DB_NAME || "./data/data.db",
|
|
276
|
+
});`;
|
|
277
|
+
}
|
|
278
|
+
return `db.connect({
|
|
279
|
+
host: process.env.DB_HOST || "localhost",
|
|
280
|
+
port: process.env.DB_PORT,
|
|
281
|
+
database: process.env.DB_NAME,
|
|
282
|
+
user: process.env.DB_USER,
|
|
283
|
+
password: process.env.DB_PASS,
|
|
284
|
+
});`;
|
|
285
|
+
}
|
|
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
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Generate session middleware block for app.js.
|
|
323
|
+
* @param {import('./types').InitAnswers} answers
|
|
324
|
+
* @returns {string}
|
|
325
|
+
*/
|
|
326
|
+
function sessionBlock(answers) {
|
|
327
|
+
if (answers.session === "redis") {
|
|
328
|
+
const redisConfig =
|
|
329
|
+
answers.database === "redis"
|
|
330
|
+
? ` host: process.env.DB_HOST || "localhost",
|
|
331
|
+
port: process.env.DB_PORT || 6379,
|
|
332
|
+
password: process.env.DB_PASS,`
|
|
333
|
+
: ` host: process.env.REDIS_HOST || "localhost",
|
|
334
|
+
port: process.env.REDIS_PORT || 6379,
|
|
335
|
+
password: process.env.REDIS_PASS,`;
|
|
336
|
+
|
|
337
|
+
return `
|
|
338
|
+
// Session with Redis store
|
|
339
|
+
const redisClient = new Redis({
|
|
340
|
+
${redisConfig}
|
|
341
|
+
});
|
|
342
|
+
app.use(session({
|
|
343
|
+
store: new RedisStore({ client: redisClient }),
|
|
344
|
+
secret: process.env.SESSION_SECRET || "change-me",
|
|
345
|
+
resave: false,
|
|
346
|
+
saveUninitialized: false,
|
|
347
|
+
}));`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const label = answers.session === "database" ? "database" : "in-memory";
|
|
351
|
+
return `
|
|
352
|
+
// Session with ${label} store
|
|
353
|
+
app.use(session({
|
|
354
|
+
secret: process.env.SESSION_SECRET || "change-me",
|
|
355
|
+
resave: false,
|
|
356
|
+
saveUninitialized: false,
|
|
357
|
+
}));`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Generate the app.js file content.
|
|
362
|
+
* @param {import('./types').InitAnswers} answers
|
|
363
|
+
* @returns {string}
|
|
364
|
+
*/
|
|
365
|
+
function generateAppJs(answers) {
|
|
366
|
+
const frameworkPkg =
|
|
367
|
+
answers.framework === "ultimate-express" ? "ultimate-express" : "express";
|
|
368
|
+
|
|
369
|
+
// Imports
|
|
370
|
+
let imports = `const express = require("${frameworkPkg}");
|
|
371
|
+
const { init, db } = require("db-model-router");
|
|
372
|
+
const session = require("express-session");`;
|
|
373
|
+
|
|
374
|
+
if (answers.session === "redis") {
|
|
375
|
+
imports += `\nconst RedisStore = require("connect-redis").default;
|
|
376
|
+
const { Redis } = require("ioredis");`;
|
|
377
|
+
}
|
|
378
|
+
if (answers.rateLimiting) {
|
|
379
|
+
imports += `\nconst rateLimit = require("express-rate-limit");`;
|
|
380
|
+
}
|
|
381
|
+
if (answers.helmet) {
|
|
382
|
+
imports += `\nconst helmet = require("helmet");`;
|
|
383
|
+
}
|
|
384
|
+
imports += `\nconst logger = require("./middleware/logger");`;
|
|
385
|
+
|
|
386
|
+
// Rate limiting block
|
|
387
|
+
const rateLimitBlock = answers.rateLimiting
|
|
388
|
+
? `app.use(rateLimit({
|
|
389
|
+
windowMs: 15 * 60 * 1000,
|
|
390
|
+
max: 100,
|
|
391
|
+
standardHeaders: true,
|
|
392
|
+
legacyHeaders: false,
|
|
393
|
+
}));`
|
|
394
|
+
: "";
|
|
395
|
+
|
|
396
|
+
const helmetBlock = answers.helmet ? `app.use(helmet());` : "";
|
|
397
|
+
|
|
398
|
+
return `${imports}
|
|
399
|
+
|
|
400
|
+
// Load environment variables
|
|
401
|
+
require("dotenv").config();
|
|
402
|
+
|
|
403
|
+
// Initialize database adapter
|
|
404
|
+
init("${answers.database}");
|
|
405
|
+
${dbConnectBlock(answers.database)}
|
|
406
|
+
|
|
407
|
+
const app = express();
|
|
408
|
+
const PORT = process.env.PORT || 3000;
|
|
409
|
+
|
|
410
|
+
// Middleware
|
|
411
|
+
app.use(express.json());
|
|
412
|
+
app.use(express.urlencoded({ extended: true }));
|
|
413
|
+
${helmetBlock ? helmetBlock + "\n" : ""}${rateLimitBlock ? rateLimitBlock + "\n" : ""}${sessionBlock(answers)}
|
|
414
|
+
app.use(logger);
|
|
415
|
+
|
|
416
|
+
// Health check
|
|
417
|
+
app.get("/health", (req, res) => {
|
|
418
|
+
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Error handler
|
|
422
|
+
app.use((err, req, res, next) => {
|
|
423
|
+
console.error(err.stack);
|
|
424
|
+
res.status(500).json({ type: "danger", message: "Internal Server Error" });
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
app.listen(PORT, () => {
|
|
428
|
+
console.log(\`Server running on port \${PORT}\`);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
module.exports = app;
|
|
432
|
+
`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// Logger middleware generator
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Generate middleware/logger.js content.
|
|
441
|
+
* When logger is enabled, uses Winston with winston-loki transport for Grafana.
|
|
442
|
+
* @param {import('./types').InitAnswers} answers
|
|
443
|
+
* @returns {string}
|
|
444
|
+
*/
|
|
445
|
+
function generateLoggerMiddleware(answers) {
|
|
446
|
+
if (answers.logger) {
|
|
447
|
+
return `import winston from "winston";
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Winston logger with Console transport.
|
|
451
|
+
* If LOKI_HOST is set in .env, adds a Loki transport for Grafana visualization.
|
|
452
|
+
*/
|
|
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
|
+
}),
|
|
477
|
+
);
|
|
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,
|
|
488
|
+
});
|
|
489
|
+
|
|
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
|
+
|
|
509
|
+
next();
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
requestLogger.logger = logger;
|
|
513
|
+
export default requestLogger;
|
|
514
|
+
`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return `/**
|
|
518
|
+
* Simple request logger middleware.
|
|
519
|
+
* Logs method, URL, status code, and response time.
|
|
520
|
+
*/
|
|
521
|
+
export default function logger(req, res, next) {
|
|
522
|
+
const start = Date.now();
|
|
523
|
+
const { method, originalUrl } = req;
|
|
524
|
+
|
|
525
|
+
res.on("finish", () => {
|
|
526
|
+
const duration = Date.now() - start;
|
|
527
|
+
const status = res.statusCode;
|
|
528
|
+
const level = status >= 400 ? "WARN" : "INFO";
|
|
529
|
+
console.log(
|
|
530
|
+
\`[\${new Date().toISOString()}] [\${level}] \${method} \${originalUrl} \${status} \${duration}ms\`,
|
|
531
|
+
);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
next();
|
|
535
|
+
}
|
|
536
|
+
`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// Migration script generators (Fix 3: migrate.js now checks tracking table)
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Generate migrate.js script content.
|
|
545
|
+
* Checks _migrations tracking table before running each migration.
|
|
546
|
+
* @param {import('./types').InitAnswers} answers
|
|
547
|
+
* @returns {string}
|
|
548
|
+
*/
|
|
549
|
+
function generateMigrateScript(answers) {
|
|
550
|
+
const isNoSql = NOSQL_DATABASES.includes(answers.database);
|
|
551
|
+
|
|
552
|
+
if (isNoSql) {
|
|
553
|
+
return `#!/usr/bin/env node
|
|
554
|
+
"use strict";
|
|
555
|
+
|
|
556
|
+
const fs = require("fs");
|
|
557
|
+
const path = require("path");
|
|
558
|
+
const crypto = require("crypto");
|
|
559
|
+
require("dotenv").config();
|
|
560
|
+
|
|
561
|
+
const { init, db } = require("db-model-router");
|
|
562
|
+
|
|
563
|
+
init("${answers.database}");
|
|
564
|
+
|
|
565
|
+
const migrationsDir = path.join(__dirname, "migrations");
|
|
566
|
+
|
|
567
|
+
async function getExecutedMigrations() {
|
|
568
|
+
try {
|
|
569
|
+
const result = await db.get("_migrations");
|
|
570
|
+
return new Set((result || []).map(r => r.filename));
|
|
571
|
+
} catch (e) {
|
|
572
|
+
return new Set();
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async function recordMigration(filename, checksum) {
|
|
577
|
+
await db.insert("_migrations", {
|
|
578
|
+
filename,
|
|
579
|
+
executed_at: new Date().toISOString(),
|
|
580
|
+
checksum,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function migrate() {
|
|
585
|
+
const files = fs.readdirSync(migrationsDir)
|
|
586
|
+
.filter(f => f.endsWith(".js"))
|
|
587
|
+
.sort();
|
|
588
|
+
|
|
589
|
+
const executed = await getExecutedMigrations();
|
|
590
|
+
let ran = 0;
|
|
591
|
+
|
|
592
|
+
for (const file of files) {
|
|
593
|
+
if (executed.has(file)) {
|
|
594
|
+
console.log(\` Skipping (already executed): \${file}\`);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const filePath = path.join(migrationsDir, file);
|
|
598
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
599
|
+
const checksum = crypto.createHash("md5").update(content).digest("hex");
|
|
600
|
+
|
|
601
|
+
const migration = require(filePath);
|
|
602
|
+
console.log(\` Running migration: \${file}\`);
|
|
603
|
+
await migration.up(db);
|
|
604
|
+
await recordMigration(file, checksum);
|
|
605
|
+
console.log(\` Completed: \${file}\`);
|
|
606
|
+
ran++;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (ran === 0) {
|
|
610
|
+
console.log("No pending migrations.");
|
|
611
|
+
} else {
|
|
612
|
+
console.log(\`\\n\${ran} migration(s) complete.\`);
|
|
613
|
+
}
|
|
614
|
+
process.exit(0);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
migrate().catch(err => {
|
|
618
|
+
console.error("Migration failed:", err);
|
|
619
|
+
process.exit(1);
|
|
620
|
+
});
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return `#!/usr/bin/env node
|
|
625
|
+
"use strict";
|
|
626
|
+
|
|
627
|
+
const fs = require("fs");
|
|
628
|
+
const path = require("path");
|
|
629
|
+
const crypto = require("crypto");
|
|
630
|
+
require("dotenv").config();
|
|
631
|
+
|
|
632
|
+
const { init, db } = require("db-model-router");
|
|
633
|
+
|
|
634
|
+
init("${answers.database}");
|
|
635
|
+
|
|
636
|
+
const migrationsDir = path.join(__dirname, "migrations");
|
|
637
|
+
|
|
638
|
+
async function getExecutedMigrations() {
|
|
639
|
+
try {
|
|
640
|
+
const result = await db.query("SELECT filename FROM _migrations");
|
|
641
|
+
return new Set((result || []).map(r => r.filename));
|
|
642
|
+
} catch (e) {
|
|
643
|
+
// Table may not exist yet (first run)
|
|
644
|
+
return new Set();
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function recordMigration(filename, checksum) {
|
|
649
|
+
await db.query(
|
|
650
|
+
"INSERT INTO _migrations (filename, checksum) VALUES (?, ?)",
|
|
651
|
+
[filename, checksum]
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function migrate() {
|
|
656
|
+
const files = fs.readdirSync(migrationsDir)
|
|
657
|
+
.filter(f => f.endsWith(".sql"))
|
|
658
|
+
.sort();
|
|
659
|
+
|
|
660
|
+
const executed = await getExecutedMigrations();
|
|
661
|
+
let ran = 0;
|
|
662
|
+
|
|
663
|
+
for (const file of files) {
|
|
664
|
+
if (executed.has(file)) {
|
|
665
|
+
console.log(\` Skipping (already executed): \${file}\`);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const filePath = path.join(migrationsDir, file);
|
|
669
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
670
|
+
const checksum = crypto.createHash("md5").update(content).digest("hex");
|
|
671
|
+
|
|
672
|
+
console.log(\` Running migration: \${file}\`);
|
|
673
|
+
await db.query(content);
|
|
674
|
+
await recordMigration(file, checksum);
|
|
675
|
+
console.log(\` Completed: \${file}\`);
|
|
676
|
+
ran++;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (ran === 0) {
|
|
680
|
+
console.log("No pending migrations.");
|
|
681
|
+
} else {
|
|
682
|
+
console.log(\`\\n\${ran} migration(s) complete.\`);
|
|
683
|
+
}
|
|
684
|
+
process.exit(0);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
migrate().catch(err => {
|
|
688
|
+
console.error("Migration failed:", err);
|
|
689
|
+
process.exit(1);
|
|
690
|
+
});
|
|
691
|
+
`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Generate add_migration.js script content.
|
|
696
|
+
* @param {import('./types').InitAnswers} answers
|
|
697
|
+
* @returns {string}
|
|
698
|
+
*/
|
|
699
|
+
function generateAddMigrationScript(answers) {
|
|
700
|
+
const isNoSql = NOSQL_DATABASES.includes(answers.database);
|
|
701
|
+
const ext = isNoSql ? "js" : "sql";
|
|
702
|
+
const template = isNoSql
|
|
703
|
+
? `"use strict";\\n\\nmodule.exports = {\\n async up(db) {\\n // Write your migration here\\n },\\n\\n async down(db) {\\n // Write your rollback here\\n },\\n};\\n`
|
|
704
|
+
: `-- Write your migration SQL here\\n`;
|
|
705
|
+
|
|
706
|
+
return `#!/usr/bin/env node
|
|
707
|
+
"use strict";
|
|
708
|
+
|
|
709
|
+
const fs = require("fs");
|
|
710
|
+
const path = require("path");
|
|
711
|
+
|
|
712
|
+
const migrationsDir = path.join(__dirname, "migrations");
|
|
713
|
+
|
|
714
|
+
function migrationTimestamp() {
|
|
715
|
+
const now = new Date();
|
|
716
|
+
const y = String(now.getFullYear()).padStart(4, "0");
|
|
717
|
+
const mo = String(now.getMonth() + 1).padStart(2, "0");
|
|
718
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
719
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
720
|
+
const mi = String(now.getMinutes()).padStart(2, "0");
|
|
721
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
722
|
+
return \`\${y}\${mo}\${d}\${h}\${mi}\${s}\`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const name = process.argv[2] || "migration";
|
|
726
|
+
const filename = \`\${migrationTimestamp()}_\${name}.${ext}\`;
|
|
727
|
+
const filePath = path.join(migrationsDir, filename);
|
|
728
|
+
|
|
729
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
730
|
+
fs.mkdirSync(migrationsDir, { recursive: true });
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
fs.writeFileSync(filePath, "${template}");
|
|
734
|
+
console.log(\`Created migration: \${filename}\`);
|
|
735
|
+
`;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ---------------------------------------------------------------------------
|
|
739
|
+
// Initial migration + session migration generators
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Generate the initial migration file that creates the _migrations tracking table.
|
|
744
|
+
* @param {import('./types').InitAnswers} answers
|
|
745
|
+
* @param {Date} [date]
|
|
746
|
+
* @returns {{ filename: string, content: string }}
|
|
747
|
+
*/
|
|
748
|
+
function generateInitialMigration(answers, date) {
|
|
749
|
+
const ts = migrationTimestamp(date || new Date());
|
|
750
|
+
|
|
751
|
+
if (isSql(answers.database)) {
|
|
752
|
+
let content;
|
|
753
|
+
if (answers.database === "postgres" || answers.database === "cockroachdb") {
|
|
754
|
+
content = `CREATE TABLE IF NOT EXISTS _migrations (
|
|
755
|
+
id SERIAL PRIMARY KEY,
|
|
756
|
+
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
757
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
758
|
+
checksum VARCHAR(64) NOT NULL
|
|
759
|
+
);
|
|
760
|
+
`;
|
|
761
|
+
} else if (answers.database === "mssql") {
|
|
762
|
+
content = `CREATE TABLE _migrations (
|
|
763
|
+
id INT IDENTITY(1,1) PRIMARY KEY,
|
|
764
|
+
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
765
|
+
executed_at DATETIME DEFAULT GETDATE(),
|
|
766
|
+
checksum VARCHAR(64) NOT NULL
|
|
767
|
+
);
|
|
768
|
+
`;
|
|
769
|
+
} else if (answers.database === "oracle") {
|
|
770
|
+
content = `CREATE TABLE _migrations (
|
|
771
|
+
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
|
772
|
+
filename VARCHAR2(255) NOT NULL UNIQUE,
|
|
773
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
774
|
+
checksum VARCHAR2(64) NOT NULL
|
|
775
|
+
);
|
|
776
|
+
`;
|
|
777
|
+
} else {
|
|
778
|
+
// mysql, sqlite3
|
|
779
|
+
content = `CREATE TABLE IF NOT EXISTS _migrations (
|
|
780
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
781
|
+
filename VARCHAR(255) NOT NULL UNIQUE,
|
|
782
|
+
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
783
|
+
checksum VARCHAR(64) NOT NULL
|
|
784
|
+
);
|
|
785
|
+
`;
|
|
786
|
+
}
|
|
787
|
+
return { filename: `${ts}_create_migrations_table.sql`, content };
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// NoSQL databases
|
|
791
|
+
let content;
|
|
792
|
+
if (answers.database === "mongodb") {
|
|
793
|
+
content = `export async function up(db) {
|
|
794
|
+
await db.createCollection("_migrations");
|
|
795
|
+
await db.collection("_migrations").createIndex({ filename: 1 }, { unique: true });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export async function down(db) {
|
|
799
|
+
await db.collection("_migrations").drop();
|
|
800
|
+
}
|
|
801
|
+
`;
|
|
802
|
+
} else if (answers.database === "redis") {
|
|
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
|
+
}
|
|
807
|
+
|
|
808
|
+
export async function down(db) {
|
|
809
|
+
await db.del("_migrations");
|
|
810
|
+
}
|
|
811
|
+
`;
|
|
812
|
+
} else {
|
|
813
|
+
// dynamodb
|
|
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
|
+
}
|
|
824
|
+
|
|
825
|
+
export async function down(db) {
|
|
826
|
+
await db.send(new DeleteTableCommand({ TableName: "_migrations" }));
|
|
827
|
+
}
|
|
828
|
+
`;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return { filename: `${ts}_create_migrations_table.js`, content };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Generate the session migration file for SQL databases with database session store.
|
|
836
|
+
* @param {import('./types').InitAnswers} answers
|
|
837
|
+
* @param {Date} [date]
|
|
838
|
+
* @returns {{ filename: string, content: string } | null}
|
|
839
|
+
*/
|
|
840
|
+
function generateSessionMigration(answers, date) {
|
|
841
|
+
if (answers.session !== "database" || !isSql(answers.database)) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const ts = migrationTimestamp(date || new Date());
|
|
846
|
+
|
|
847
|
+
let content;
|
|
848
|
+
if (answers.database === "postgres" || answers.database === "cockroachdb") {
|
|
849
|
+
content = `CREATE TABLE IF NOT EXISTS sessions (
|
|
850
|
+
sid VARCHAR(255) PRIMARY KEY,
|
|
851
|
+
sess TEXT NOT NULL,
|
|
852
|
+
expired_at TIMESTAMP NOT NULL
|
|
853
|
+
);
|
|
854
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired_at);
|
|
855
|
+
`;
|
|
856
|
+
} else if (answers.database === "mssql") {
|
|
857
|
+
content = `CREATE TABLE sessions (
|
|
858
|
+
sid VARCHAR(255) PRIMARY KEY,
|
|
859
|
+
sess TEXT NOT NULL,
|
|
860
|
+
expired_at DATETIME NOT NULL
|
|
861
|
+
);
|
|
862
|
+
CREATE INDEX idx_sessions_expired ON sessions(expired_at);
|
|
863
|
+
`;
|
|
864
|
+
} else if (answers.database === "oracle") {
|
|
865
|
+
content = `CREATE TABLE sessions (
|
|
866
|
+
sid VARCHAR2(255) PRIMARY KEY,
|
|
867
|
+
sess CLOB NOT NULL,
|
|
868
|
+
expired_at TIMESTAMP NOT NULL
|
|
869
|
+
);
|
|
870
|
+
CREATE INDEX idx_sessions_expired ON sessions(expired_at);
|
|
871
|
+
`;
|
|
872
|
+
} else {
|
|
873
|
+
// mysql, sqlite3
|
|
874
|
+
content = `CREATE TABLE IF NOT EXISTS sessions (
|
|
875
|
+
sid VARCHAR(255) PRIMARY KEY,
|
|
876
|
+
sess TEXT NOT NULL,
|
|
877
|
+
expired_at TIMESTAMP NOT NULL
|
|
878
|
+
);
|
|
879
|
+
CREATE INDEX idx_sessions_expired ON sessions(expired_at);
|
|
880
|
+
`;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return { filename: `${ts}_create_sessions_table.sql`, content };
|
|
884
|
+
}
|
|
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
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Generate .gitignore content.
|
|
1291
|
+
* @returns {string}
|
|
1292
|
+
*/
|
|
1293
|
+
function generateGitignore() {
|
|
1294
|
+
return `node_modules/
|
|
1295
|
+
.env
|
|
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}/route` : "./route";
|
|
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;
|
|
1760
|
+
`;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
module.exports = {
|
|
1764
|
+
migrationTimestamp,
|
|
1765
|
+
isSql,
|
|
1766
|
+
randomPassword,
|
|
1767
|
+
generateAppJs,
|
|
1768
|
+
generateAppJsV2,
|
|
1769
|
+
generateEnvFile,
|
|
1770
|
+
generateEnvExample,
|
|
1771
|
+
generateLoggerMiddleware,
|
|
1772
|
+
generateMigrateScript,
|
|
1773
|
+
generateAddMigrationScript,
|
|
1774
|
+
generateInitialMigration,
|
|
1775
|
+
generateSessionMigration,
|
|
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,
|
|
1789
|
+
SQL_DATABASES,
|
|
1790
|
+
NOSQL_DATABASES,
|
|
1791
|
+
};
|