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.
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+
3
+ const inquirer = require("inquirer");
4
+
5
+ const VALID_FRAMEWORKS = ["ultimate-express", "express"];
6
+ const VALID_DATABASES = [
7
+ "mysql",
8
+ "mariadb",
9
+ "postgres",
10
+ "sqlite3",
11
+ "mongodb",
12
+ "mssql",
13
+ "cockroachdb",
14
+ "oracle",
15
+ "redis",
16
+ "dynamodb",
17
+ ];
18
+ const VALID_SESSIONS = ["memory", "redis", "database"];
19
+
20
+ /**
21
+ * Parse CLI arguments into a partial answers object.
22
+ * Supports: --framework, --database, --session, --rateLimiting, --helmet, --logger
23
+ * Boolean flags accept: true/false, yes/no, 1/0, or just --flag (implies true)
24
+ * @param {string[]} argv
25
+ * @returns {Partial<import('./types').InitAnswers>}
26
+ */
27
+ function parseInitArgs(argv) {
28
+ const args = {};
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const arg = argv[i];
31
+ if (arg.startsWith("--")) {
32
+ const key = arg.slice(2);
33
+ const next = argv[i + 1];
34
+ if (next && !next.startsWith("--")) {
35
+ args[key] = next;
36
+ i++;
37
+ } else {
38
+ args[key] = "true";
39
+ }
40
+ }
41
+ }
42
+
43
+ const partial = {};
44
+
45
+ if (args.framework && VALID_FRAMEWORKS.includes(args.framework)) {
46
+ partial.framework = args.framework;
47
+ }
48
+ if (args.database && VALID_DATABASES.includes(args.database)) {
49
+ partial.database = args.database;
50
+ }
51
+ // Accept --db as alias for --database
52
+ if (!partial.database && args.db && VALID_DATABASES.includes(args.db)) {
53
+ partial.database = args.db;
54
+ }
55
+ if (args.session && VALID_SESSIONS.includes(args.session)) {
56
+ partial.session = args.session;
57
+ }
58
+
59
+ // Boolean flags
60
+ for (const flag of ["rateLimiting", "helmet", "logger", "loki"]) {
61
+ if (args[flag] !== undefined) {
62
+ partial[flag] = parseBool(args[flag]);
63
+ }
64
+ }
65
+
66
+ // Output directory
67
+ if (args.output !== undefined && args.output !== "true") {
68
+ partial.output = args.output;
69
+ }
70
+
71
+ return partial;
72
+ }
73
+
74
+ /**
75
+ * Parse a string as a boolean.
76
+ * @param {string} val
77
+ * @returns {boolean}
78
+ */
79
+ function parseBool(val) {
80
+ return ["true", "yes", "1"].includes(String(val).toLowerCase());
81
+ }
82
+
83
+ /**
84
+ * Run the interactive prompt flow to collect user preferences.
85
+ * Any values already present in `prefilledAnswers` are skipped (not prompted).
86
+ * When all 6 values are provided, no prompts are shown at all.
87
+ * @param {Partial<import('./types').InitAnswers>} [prefilledAnswers={}]
88
+ * @returns {Promise<import('./types').InitAnswers>}
89
+ */
90
+ async function promptUser(prefilledAnswers) {
91
+ const prefilled = prefilledAnswers || {};
92
+
93
+ const questions = [];
94
+
95
+ if (prefilled.framework === undefined) {
96
+ questions.push({
97
+ type: "list",
98
+ name: "framework",
99
+ message: "Select your Express framework:",
100
+ choices: VALID_FRAMEWORKS,
101
+ default: "ultimate-express",
102
+ });
103
+ }
104
+
105
+ if (prefilled.database === undefined) {
106
+ questions.push({
107
+ type: "list",
108
+ name: "database",
109
+ message: "Select your database:",
110
+ choices: VALID_DATABASES,
111
+ });
112
+ }
113
+
114
+ if (prefilled.session === undefined) {
115
+ questions.push({
116
+ type: "list",
117
+ name: "session",
118
+ message: "Select your session store:",
119
+ choices: VALID_SESSIONS,
120
+ });
121
+ }
122
+
123
+ if (prefilled.output === undefined) {
124
+ questions.push({
125
+ type: "input",
126
+ name: "output",
127
+ message:
128
+ "Output directory for backend source files (leave empty for root):",
129
+ default: "",
130
+ });
131
+ }
132
+
133
+ if (prefilled.rateLimiting === undefined) {
134
+ questions.push({
135
+ type: "confirm",
136
+ name: "rateLimiting",
137
+ message: "Enable rate limiting?",
138
+ default: true,
139
+ });
140
+ }
141
+
142
+ if (prefilled.helmet === undefined) {
143
+ questions.push({
144
+ type: "confirm",
145
+ name: "helmet",
146
+ message: "Enable Helmet security headers?",
147
+ default: true,
148
+ });
149
+ }
150
+
151
+ if (prefilled.logger === undefined) {
152
+ questions.push({
153
+ type: "confirm",
154
+ name: "logger",
155
+ message: "Enable request/response logger (Winston)?",
156
+ default: true,
157
+ });
158
+ }
159
+
160
+ // If all values are prefilled, skip prompts entirely
161
+ if (questions.length === 0) {
162
+ return /** @type {import('./types').InitAnswers} */ (prefilled);
163
+ }
164
+
165
+ const prompted = await inquirer.prompt(questions);
166
+
167
+ // Follow-up: if logger is enabled, ask about Loki
168
+ if (prompted.logger && prefilled.loki === undefined) {
169
+ const lokiAnswer = await inquirer.prompt([
170
+ {
171
+ type: "confirm",
172
+ name: "loki",
173
+ message: "Send logs to Grafana Loki?",
174
+ default: false,
175
+ },
176
+ ]);
177
+ prompted.loki = lokiAnswer.loki;
178
+ } else if (!prompted.logger && prefilled.logger === undefined) {
179
+ prompted.loki = false;
180
+ }
181
+
182
+ return Object.assign({}, prefilled, prompted);
183
+ }
184
+
185
+ module.exports = {
186
+ promptUser,
187
+ parseInitArgs,
188
+ VALID_FRAMEWORKS,
189
+ VALID_DATABASES,
190
+ VALID_SESSIONS,
191
+ };
@@ -0,0 +1,404 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const { execSync } = require("child_process");
7
+
8
+ const {
9
+ generateAppJs,
10
+ generateAppJsV2,
11
+ generateEnvFile,
12
+ generateEnvExample,
13
+ generateLoggerMiddleware,
14
+ generateInitialMigration,
15
+ generateSessionMigration,
16
+ generateGitignore,
17
+ generateDockerfile,
18
+ generateDockerignore,
19
+ generateGrafanaDatasources,
20
+ generateDockerCompose,
21
+ generateCloudBeaverDataSources,
22
+ generateSessionJs,
23
+ generateMigrateModule,
24
+ generateAddMigrationModule,
25
+ generateSecurityJs,
26
+ generateHealthRoute,
27
+ generateRouteIndexFile,
28
+ generateDbModule,
29
+ randomPassword,
30
+ } = require("./init/generators");
31
+
32
+ const { collectDependencies, getScripts } = require("./init/dependencies");
33
+ const { promptUser, parseInitArgs } = require("./init/prompt");
34
+
35
+ /**
36
+ * Ensure a package.json exists in the current directory.
37
+ * Uses `npm init -y` for non-interactive creation. Exits with code 1 on failure.
38
+ */
39
+ function ensurePackageJson() {
40
+ if (fs.existsSync("package.json")) {
41
+ return;
42
+ }
43
+ try {
44
+ execSync("npm init -y", { stdio: "inherit" });
45
+ } catch (err) {
46
+ console.error("Error: npm init failed or was aborted.");
47
+ process.exit(1);
48
+ }
49
+ if (!fs.existsSync("package.json")) {
50
+ console.error("Error: package.json was not created. Aborting.");
51
+ process.exit(1);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Write a file, but warn (and skip) if it already exists.
57
+ * @param {string} filePath
58
+ * @param {string} content
59
+ * @returns {boolean} true if written, false if skipped
60
+ */
61
+ function safeWriteFile(filePath, content) {
62
+ if (fs.existsSync(filePath)) {
63
+ console.log(` Skipped ${filePath} (already exists)`);
64
+ return false;
65
+ }
66
+ fs.writeFileSync(filePath, content);
67
+ console.log(` Created ${filePath}`);
68
+ return true;
69
+ }
70
+
71
+ /**
72
+ * Generate all project files based on user answers.
73
+ * Creates directories and writes files. Skips files that already exist.
74
+ * Returns the list of generated filenames for the summary.
75
+ * @param {import('./init/types').InitAnswers} answers
76
+ * @param {string} [outputDir] - relative directory for source files (e.g. "backend")
77
+ * @returns {{ files: string[], migrationFiles: string[] }}
78
+ */
79
+ function generateFiles(answers, outputDir) {
80
+ const files = [];
81
+ const migrationFiles = [];
82
+
83
+ // Resolve source directory (outputDir-relative or cwd)
84
+ const srcBase = outputDir || ".";
85
+
86
+ // Generate random secrets (shared between .env and docker-compose)
87
+ const secrets = {
88
+ dbPass: randomPassword(),
89
+ redisPass: answers.session === "redis" ? randomPassword() : "",
90
+ sessionSecret: randomPassword(32),
91
+ };
92
+
93
+ // Create directories
94
+ const dirs = [
95
+ path.join(srcBase, "middleware"),
96
+ path.join(srcBase, "migrations"),
97
+ path.join(srcBase, "commons"),
98
+ path.join(srcBase, "route"),
99
+ ];
100
+ // SQLite3 needs a data/ folder for the database file
101
+ if (answers.database === "sqlite3") {
102
+ dirs.push("data");
103
+ }
104
+ for (const dir of dirs) {
105
+ if (!fs.existsSync(dir)) {
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ }
108
+ }
109
+
110
+ // Root-level files (always in cwd, not in outputDir)
111
+ // app.js uses the v2 generator that links commons/route modules
112
+ if (safeWriteFile("app.js", generateAppJsV2(answers, outputDir || "")))
113
+ files.push("app.js");
114
+ if (safeWriteFile(".env", generateEnvFile(answers, secrets)))
115
+ files.push(".env");
116
+ if (safeWriteFile(".env.example", generateEnvExample(answers)))
117
+ files.push(".env.example");
118
+ if (safeWriteFile(".gitignore", generateGitignore()))
119
+ files.push(".gitignore");
120
+
121
+ // docker-compose.yml (if the database needs Docker)
122
+ const dockerCompose = generateDockerCompose(answers, secrets);
123
+ if (dockerCompose !== null) {
124
+ if (safeWriteFile("docker-compose.yml", dockerCompose))
125
+ files.push("docker-compose.yml");
126
+ }
127
+
128
+ // CloudBeaver data-sources.json (auto-connect config)
129
+ const cbDataSources = generateCloudBeaverDataSources(answers, secrets);
130
+ if (cbDataSources !== null) {
131
+ const cbDir = ".cloudbeaver";
132
+ if (!fs.existsSync(cbDir)) {
133
+ fs.mkdirSync(cbDir, { recursive: true });
134
+ }
135
+ const cbPath = path.join(cbDir, "data-sources.json");
136
+ if (safeWriteFile(cbPath, cbDataSources))
137
+ files.push(".cloudbeaver/data-sources.json");
138
+ }
139
+
140
+ // Grafana datasource provisioning (when loki is enabled)
141
+ if (answers.loki) {
142
+ const grafanaDir = ".grafana";
143
+ if (!fs.existsSync(grafanaDir)) {
144
+ fs.mkdirSync(grafanaDir, { recursive: true });
145
+ }
146
+ const grafanaPath = path.join(grafanaDir, "datasources.yml");
147
+ if (safeWriteFile(grafanaPath, generateGrafanaDatasources()))
148
+ files.push(".grafana/datasources.yml");
149
+ }
150
+
151
+ // Dockerfile and .dockerignore
152
+ if (safeWriteFile("Dockerfile", generateDockerfile(answers, outputDir)))
153
+ files.push("Dockerfile");
154
+ if (safeWriteFile(".dockerignore", generateDockerignore()))
155
+ files.push(".dockerignore");
156
+
157
+ // Source files inside outputDir (or cwd if no outputDir)
158
+ const loggerPath = path.join(srcBase, "middleware", "logger.js");
159
+ if (safeWriteFile(loggerPath, generateLoggerMiddleware(answers)))
160
+ files.push(path.join(srcBase, "middleware/logger.js"));
161
+
162
+ // commons/session.js
163
+ const sessionPath = path.join(srcBase, "commons", "session.js");
164
+ if (safeWriteFile(sessionPath, generateSessionJs(answers)))
165
+ files.push(path.join(srcBase, "commons/session.js"));
166
+
167
+ // commons/migrate.js
168
+ const migratePath = path.join(srcBase, "commons", "migrate.js");
169
+ if (safeWriteFile(migratePath, generateMigrateModule(answers, outputDir)))
170
+ files.push(path.join(srcBase, "commons/migrate.js"));
171
+
172
+ // commons/add_migration.js
173
+ const addMigrationPath = path.join(srcBase, "commons", "add_migration.js");
174
+ if (
175
+ safeWriteFile(
176
+ addMigrationPath,
177
+ generateAddMigrationModule(answers, outputDir),
178
+ )
179
+ )
180
+ files.push(path.join(srcBase, "commons/add_migration.js"));
181
+
182
+ // commons/security.js
183
+ const securityPath = path.join(srcBase, "commons", "security.js");
184
+ if (safeWriteFile(securityPath, generateSecurityJs(answers)))
185
+ files.push(path.join(srcBase, "commons/security.js"));
186
+
187
+ // commons/db.js
188
+ const dbPath = path.join(srcBase, "commons", "db.js");
189
+ if (safeWriteFile(dbPath, generateDbModule(answers)))
190
+ files.push(path.join(srcBase, "commons/db.js"));
191
+
192
+ // route/health.js
193
+ const healthPath = path.join(srcBase, "route", "health.js");
194
+ if (safeWriteFile(healthPath, generateHealthRoute()))
195
+ files.push(path.join(srcBase, "route/health.js"));
196
+
197
+ // route/index.js
198
+ const routeIndexPath = path.join(srcBase, "route", "index.js");
199
+ if (safeWriteFile(routeIndexPath, generateRouteIndexFile()))
200
+ files.push(path.join(srcBase, "route/index.js"));
201
+
202
+ // Initial migration (inside outputDir/migrations)
203
+ const initialMigration = generateInitialMigration(answers);
204
+ const initialPath = path.join(
205
+ srcBase,
206
+ "migrations",
207
+ initialMigration.filename,
208
+ );
209
+ if (safeWriteFile(initialPath, initialMigration.content)) {
210
+ migrationFiles.push(initialMigration.filename);
211
+ }
212
+
213
+ // Conditional session migration
214
+ const sessionMigration = generateSessionMigration(answers);
215
+ if (sessionMigration !== null) {
216
+ const sessionMigPath = path.join(
217
+ srcBase,
218
+ "migrations",
219
+ sessionMigration.filename,
220
+ );
221
+ if (safeWriteFile(sessionMigPath, sessionMigration.content)) {
222
+ migrationFiles.push(sessionMigration.filename);
223
+ }
224
+ }
225
+
226
+ return { files, migrationFiles };
227
+ }
228
+
229
+ /**
230
+ * Update package.json with scripts and dependencies from the answers.
231
+ * @param {import('./init/types').InitAnswers} answers
232
+ * @param {string} [outputDir] - relative output directory for source files
233
+ */
234
+ function updatePackageJson(answers, outputDir) {
235
+ let raw;
236
+ try {
237
+ raw = fs.readFileSync("package.json", "utf8");
238
+ } catch (err) {
239
+ console.error("Error: Could not read package.json.");
240
+ process.exit(1);
241
+ }
242
+
243
+ let pkg;
244
+ try {
245
+ pkg = JSON.parse(raw);
246
+ } catch (err) {
247
+ console.error(
248
+ "Error: package.json contains invalid JSON. Please fix it manually and re-run.",
249
+ );
250
+ process.exit(1);
251
+ }
252
+
253
+ const { dependencies, devDependencies } = collectDependencies(answers);
254
+ const scripts = getScripts(outputDir);
255
+
256
+ pkg.type = "module";
257
+ pkg.scripts = Object.assign({}, pkg.scripts || {}, scripts);
258
+ pkg.dependencies = Object.assign({}, pkg.dependencies || {}, dependencies);
259
+ pkg.devDependencies = Object.assign(
260
+ {},
261
+ pkg.devDependencies || {},
262
+ devDependencies,
263
+ );
264
+
265
+ fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
266
+ console.log(" Updated package.json");
267
+ }
268
+
269
+ /**
270
+ * Run npm install. On failure, print manual install instructions and exit with code 1.
271
+ */
272
+ function runInstall() {
273
+ console.log("\nInstalling dependencies...\n");
274
+ try {
275
+ execSync("npm install", { stdio: "inherit" });
276
+ } catch (err) {
277
+ console.error(
278
+ "\nError: npm install failed. Please run the following command manually:",
279
+ );
280
+ console.error(" npm install");
281
+ process.exit(1);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Print a summary of generated files and next-step instructions.
287
+ * Uses the file lists captured during generation (no re-calling generators).
288
+ * @param {{ files: string[], migrationFiles: string[] }} generated
289
+ */
290
+ function printSummary(generated) {
291
+ console.log("\n✔ Project scaffolded successfully!\n");
292
+ console.log("Generated files:");
293
+
294
+ for (const f of generated.files) {
295
+ console.log(` ${f}`);
296
+ }
297
+ if (generated.migrationFiles.length > 0) {
298
+ console.log(" migrations/");
299
+ for (const m of generated.migrationFiles) {
300
+ console.log(` └── ${m}`);
301
+ }
302
+ }
303
+
304
+ console.log("\nNext steps:");
305
+ console.log(" 1. Edit .env with your database credentials");
306
+ console.log(" 2. Run: npm run dev");
307
+ }
308
+
309
+ /**
310
+ * Main orchestrator.
311
+ */
312
+ async function main() {
313
+ const cliArgs = parseInitArgs(process.argv.slice(2));
314
+
315
+ // If --help flag, print usage and exit
316
+ if (process.argv.includes("--help")) {
317
+ printUsage();
318
+ process.exit(0);
319
+ }
320
+
321
+ ensurePackageJson();
322
+
323
+ let answers;
324
+ try {
325
+ answers = await promptUser(cliArgs);
326
+ } catch (err) {
327
+ // Handle Ctrl+C (inquirer throws on user cancel)
328
+ console.log("\nAborted.");
329
+ process.exit(1);
330
+ }
331
+
332
+ let generated;
333
+ try {
334
+ generated = generateFiles(answers);
335
+ } catch (err) {
336
+ if (err.code === "EACCES" || err.code === "EPERM") {
337
+ console.error(
338
+ `Error: Permission denied writing files. Check directory permissions.\n ${err.message}`,
339
+ );
340
+ } else {
341
+ console.error(`Error: Failed to generate files.\n ${err.message}`);
342
+ }
343
+ process.exit(1);
344
+ }
345
+
346
+ updatePackageJson(answers);
347
+ runInstall();
348
+ printSummary(generated);
349
+ }
350
+
351
+ /**
352
+ * Print CLI usage information.
353
+ */
354
+ function printUsage() {
355
+ console.log(`
356
+ Usage: db-model-router-init [options]
357
+
358
+ Scaffolds a complete Express-based REST API project. When all options are
359
+ provided, runs non-interactively (no prompts). Missing options are prompted.
360
+
361
+ Options:
362
+ --framework <name> Express framework: ultimate-express, express
363
+ --database <name> Database: mysql, postgres, sqlite3, mongodb, mssql,
364
+ cockroachdb, oracle, redis, dynamodb
365
+ --db <name> Alias for --database
366
+ --session <type> Session store: memory, redis, database
367
+ --output <dir> Directory for backend source files (e.g. --output backend).
368
+ package.json stays in root; index.js, commons/, route/,
369
+ middleware/, and migrations/ go inside the output folder.
370
+ --rateLimiting Enable rate limiting (express-rate-limit)
371
+ --helmet Enable Helmet security headers
372
+ --logger Enable request/response logger (express-mung)
373
+ --help Show this help message
374
+
375
+ Examples:
376
+ # Fully non-interactive (LLM-friendly)
377
+ db-model-router-init --framework express --database postgres --session redis --rateLimiting --helmet --logger
378
+
379
+ # With output directory
380
+ db-model-router-init --framework express --database postgres --output backend --yes
381
+
382
+ # Partial — only prompts for missing values
383
+ db-model-router-init --database mysql --session memory
384
+
385
+ # Interactive (no flags)
386
+ db-model-router-init
387
+ `);
388
+ }
389
+
390
+ if (require.main === module) {
391
+ main().catch((err) => {
392
+ console.error("Error:", err.message);
393
+ process.exit(1);
394
+ });
395
+ }
396
+
397
+ module.exports = {
398
+ ensurePackageJson,
399
+ generateFiles,
400
+ updatePackageJson,
401
+ runInstall,
402
+ printSummary,
403
+ main,
404
+ };