@tndhuy/create-app 1.0.0 → 1.2.2

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/dist/cli.js CHANGED
@@ -26,6 +26,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  // src/cli.ts
27
27
  var import_prompts = require("@clack/prompts");
28
28
  var import_path3 = require("path");
29
+ var import_promises3 = require("fs/promises");
30
+ var import_execa = require("execa");
29
31
 
30
32
  // src/scaffold.ts
31
33
  var import_promises2 = require("fs/promises");
@@ -38,6 +40,7 @@ function toPascalCase(kebab) {
38
40
  function buildReplacements(serviceName) {
39
41
  return [
40
42
  ["nestjs-backend-template", serviceName],
43
+ ["{{SERVICE_NAME_KEBAB}}", serviceName],
41
44
  ["NestjsBackendTemplate", toPascalCase(serviceName)]
42
45
  ];
43
46
  }
@@ -156,18 +159,48 @@ async function collectPaths(dir) {
156
159
  }
157
160
  return results;
158
161
  }
159
- async function replaceFileContents(dir, replacements) {
162
+ async function replaceFileContents(dir, replacements, options) {
160
163
  const entries = await (0, import_promises2.readdir)(dir, { withFileTypes: true });
161
164
  for (const entry of entries) {
162
165
  const fullPath = (0, import_path2.join)(dir, entry.name);
163
166
  if (entry.isDirectory()) {
164
- await replaceFileContents(fullPath, replacements);
167
+ await replaceFileContents(fullPath, replacements, options);
165
168
  } else {
166
169
  const binary = await isBinaryFile(fullPath);
167
170
  if (binary) continue;
168
171
  try {
169
172
  let content = await (0, import_promises2.readFile)(fullPath, "utf-8");
170
173
  let modified = false;
174
+ const isPrisma = options.orm === "prisma";
175
+ const isMongoose = options.orm === "mongoose";
176
+ const hasRedis = options.modules.includes("redis");
177
+ const hasOtel = options.modules.includes("otel");
178
+ const hasKafka = options.modules.includes("kafka");
179
+ const isPostgres = options.db === "postgres";
180
+ const isMongo = options.db === "mongo";
181
+ const checkBlock = (content2, flag, tag) => {
182
+ const startTag = `{{#IF_${tag}}}`;
183
+ const endTag = `{{/IF_${tag}}}`;
184
+ if (content2.includes(startTag)) {
185
+ if (flag) {
186
+ return content2.replaceAll(startTag, "").replaceAll(endTag, "");
187
+ } else {
188
+ const escapedStart = startTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
189
+ const escapedEnd = endTag.replace(/\{/g, "\\{").replace(/\}/g, "\\}");
190
+ const regex = new RegExp(`${escapedStart}[\\s\\S]*?${escapedEnd}`, "g");
191
+ return content2.replace(regex, "");
192
+ }
193
+ }
194
+ return content2;
195
+ };
196
+ content = checkBlock(content, isPrisma, "PRISMA");
197
+ content = checkBlock(content, isMongoose, "MONGOOSE");
198
+ content = checkBlock(content, hasRedis, "REDIS");
199
+ content = checkBlock(content, hasOtel, "OTEL");
200
+ content = checkBlock(content, hasKafka, "KAFKA");
201
+ content = checkBlock(content, isPostgres, "POSTGRES");
202
+ content = checkBlock(content, isMongo, "MONGO");
203
+ modified = content !== await (0, import_promises2.readFile)(fullPath, "utf-8");
171
204
  for (const [from, to] of replacements) {
172
205
  if (content.includes(from)) {
173
206
  content = content.replaceAll(from, to);
@@ -185,8 +218,8 @@ async function replaceFileContents(dir, replacements) {
185
218
  async function renamePathsWithPlaceholders(dir, replacements) {
186
219
  const allPaths = await collectPaths(dir);
187
220
  allPaths.sort((a, b) => {
188
- const depthA = a.split("/").length;
189
- const depthB = b.split("/").length;
221
+ const depthA = a.split(import_path2.sep).length;
222
+ const depthB = b.split(import_path2.sep).length;
190
223
  return depthB - depthA;
191
224
  });
192
225
  for (const oldPath of allPaths) {
@@ -214,17 +247,52 @@ async function patchPackageJson(destDir, options) {
214
247
  const raw = await (0, import_promises2.readFile)(pkgPath, "utf-8");
215
248
  const pkg = JSON.parse(raw);
216
249
  pkg.name = options.serviceName;
250
+ delete pkg.workspaces;
251
+ const PRISMA_LATEST = "^7.5.0";
252
+ const PRISMA_MONGO_COMPAT = "^6.0.0";
253
+ if (options.orm === "prisma") {
254
+ if (!pkg.scripts) pkg.scripts = {};
255
+ pkg.scripts["db:generate"] = "prisma generate";
256
+ pkg.scripts["db:push"] = "prisma db push";
257
+ pkg.scripts["db:pull"] = "prisma db pull";
258
+ pkg.scripts["db:studio"] = "prisma studio";
259
+ pkg.scripts["db:format"] = "prisma format";
260
+ if (options.db === "postgres") {
261
+ pkg.scripts["db:migrate:dev"] = "prisma migrate dev";
262
+ pkg.scripts["db:migrate:deploy"] = "prisma migrate deploy";
263
+ }
264
+ }
217
265
  if (options.db === "mongo") {
266
+ if (options.orm === "prisma") {
267
+ if (pkg.dependencies) {
268
+ delete pkg.dependencies["@prisma/adapter-pg"];
269
+ delete pkg.dependencies["pg"];
270
+ delete pkg.dependencies["@types/pg"];
271
+ pkg.dependencies["@prisma/client"] = PRISMA_MONGO_COMPAT;
272
+ }
273
+ if (pkg.devDependencies) {
274
+ pkg.devDependencies["prisma"] = PRISMA_MONGO_COMPAT;
275
+ }
276
+ } else {
277
+ if (pkg.dependencies) {
278
+ delete pkg.dependencies["@prisma/client"];
279
+ delete pkg.dependencies["@prisma/adapter-pg"];
280
+ delete pkg.dependencies["pg"];
281
+ delete pkg.dependencies["@types/pg"];
282
+ pkg.dependencies["mongoose"] = "^8.0.0";
283
+ pkg.dependencies["@nestjs/mongoose"] = "^11.0.0";
284
+ }
285
+ if (pkg.devDependencies) {
286
+ delete pkg.devDependencies["prisma"];
287
+ }
288
+ }
289
+ } else {
218
290
  if (pkg.dependencies) {
219
- delete pkg.dependencies["@prisma/client"];
220
- delete pkg.dependencies["@prisma/adapter-pg"];
221
- delete pkg.dependencies["pg"];
222
- delete pkg.dependencies["@types/pg"];
223
- pkg.dependencies["mongoose"] = "^8.0.0";
224
- pkg.dependencies["@nestjs/mongoose"] = "^11.0.0";
291
+ pkg.dependencies["@prisma/client"] = PRISMA_LATEST;
292
+ pkg.dependencies["@prisma/adapter-pg"] = PRISMA_LATEST;
225
293
  }
226
294
  if (pkg.devDependencies) {
227
- delete pkg.devDependencies["prisma"];
295
+ pkg.devDependencies["prisma"] = PRISMA_LATEST;
228
296
  }
229
297
  }
230
298
  if (options.modules.includes("kafka")) {
@@ -263,7 +331,8 @@ async function pathExists(p) {
263
331
  async function safeDeleteFile(destDir, filePath) {
264
332
  const resolved = (0, import_path2.resolve)(filePath);
265
333
  const resolvedDestDir = (0, import_path2.resolve)(destDir);
266
- if (!resolved.startsWith(resolvedDestDir + "/") && resolved !== resolvedDestDir) {
334
+ const prefix = resolvedDestDir.endsWith(import_path2.sep) ? resolvedDestDir : resolvedDestDir + import_path2.sep;
335
+ if (!resolved.startsWith(prefix) && resolved !== resolvedDestDir) {
267
336
  throw new Error(`Security: path '${filePath}' is outside destDir '${destDir}'`);
268
337
  }
269
338
  try {
@@ -274,7 +343,8 @@ async function safeDeleteFile(destDir, filePath) {
274
343
  async function safeDeleteDir(destDir, dirPath) {
275
344
  const resolved = (0, import_path2.resolve)(dirPath);
276
345
  const resolvedDestDir = (0, import_path2.resolve)(destDir);
277
- if (!resolved.startsWith(resolvedDestDir + "/") && resolved !== resolvedDestDir) {
346
+ const prefix = resolvedDestDir.endsWith(import_path2.sep) ? resolvedDestDir : resolvedDestDir + import_path2.sep;
347
+ if (!resolved.startsWith(prefix) && resolved !== resolvedDestDir) {
278
348
  throw new Error(`Security: path '${dirPath}' is outside destDir '${destDir}'`);
279
349
  }
280
350
  try {
@@ -367,8 +437,31 @@ async function removeOtel(destDir) {
367
437
  // Remove: import otelSdk from './instrumentation';
368
438
  /^import\s+\w+\s+from\s+['"][^'"]*instrumentation['"];\n?/m,
369
439
  // Remove: otelSdk?.start(); line
370
- /^\s*\w+Sdk\?\.start\(\);\n?/m
440
+ /^\s*\w+Sdk\?\.start\(\);\n?/m,
441
+ // Remove shutdown logic: void otelSdk?.shutdown()...
442
+ /^\s*void\s+otelSdk\?\.shutdown\(\)\.catch\(\(\)\s*=>\s*undefined\);\n?/m
371
443
  ]);
444
+ const pinoConfigPath = (0, import_path2.join)(destDir, "src", "shared", "logger", "pino.config.ts");
445
+ if (await pathExists(pinoConfigPath)) {
446
+ await removeMatchingLines(pinoConfigPath, [
447
+ // Remove OTel imports
448
+ /^import\s*\{[^}]*trace[^}]*\}\s*from\s*['"]@opentelemetry\/api['"];\n?/m,
449
+ // Remove mixin block (more robust regex for multiline)
450
+ /^\s*mixin\(\)\s*\{[\s\S]*?\n\s*\},\n/m
451
+ ]);
452
+ }
453
+ const redisServicePath = (0, import_path2.join)(destDir, "src", "infrastructure", "cache", "redis.service.ts");
454
+ if (await pathExists(redisServicePath)) {
455
+ await removeMatchingLines(redisServicePath, [
456
+ /^import\s*\{[^}]*trace[^}]*\}\s*from\s*['"]@opentelemetry\/api['"];\n?/m,
457
+ // Remove tracing spans from methods
458
+ /^\s*const\s+span\s*=\s*trace\.getTracer[\s\S]*?span\.end\(\);\n/gm,
459
+ // Fallback: remove any remaining span.end() or span related lines
460
+ /^\s*span\.end\(\);\n/gm,
461
+ /^\s*const\s+span\s*=\s*trace\.getTracer.*\n/gm
462
+ ]);
463
+ }
464
+ await safeDeleteFile(destDir, (0, import_path2.join)(destDir, "prometheus.yml"));
372
465
  }
373
466
  async function addKafka(destDir, serviceName) {
374
467
  await generateKafkaModule(destDir, serviceName);
@@ -402,8 +495,8 @@ $1`
402
495
  /(imports:\s*\[[^\]]*?)(\s*\])/s,
403
496
  (match, arrayContent, closing) => {
404
497
  const trimmed = arrayContent.trimEnd();
405
- const sep = trimmed.endsWith(",") ? "" : ",";
406
- return `${trimmed}${sep}
498
+ const sep2 = trimmed.endsWith(",") ? "" : ",";
499
+ return `${trimmed}${sep2}
407
500
  KafkaModule,${closing}`;
408
501
  }
409
502
  );
@@ -425,13 +518,50 @@ $1`
425
518
  }
426
519
  }
427
520
  async function scaffold(options) {
428
- const templateDir = (0, import_path2.join)(__dirname, "..", "templates", options.db);
429
- const { destDir, serviceName, modules } = options;
521
+ const templateName = options.orm === "prisma" ? "postgres" : options.db;
522
+ const templateDir = (0, import_path2.join)(__dirname, "..", "templates", templateName);
523
+ const { destDir, serviceName, modules, dryRun } = options;
524
+ if (dryRun) {
525
+ console.log(`
526
+ [Dry Run] Would copy template from ${templateName} to ${destDir}`);
527
+ console.log(` [Dry Run] Would replace placeholders for: ${serviceName}`);
528
+ console.log(` [Dry Run] Would apply modules: ${modules.join(", ") || "none"}
529
+ `);
530
+ return;
531
+ }
430
532
  await (0, import_promises2.cp)(templateDir, destDir, { recursive: true });
431
533
  const replacements = buildReplacements(serviceName);
432
- await replaceFileContents(destDir, replacements);
534
+ await replaceFileContents(destDir, replacements, options);
433
535
  await renamePathsWithPlaceholders(destDir, replacements);
434
536
  await patchPackageJson(destDir, options);
537
+ if (options.db === "mongo" && options.orm === "prisma") {
538
+ const schemaPath = (0, import_path2.join)(destDir, "prisma", "schema.prisma");
539
+ if (await pathExists(schemaPath)) {
540
+ try {
541
+ let content = await (0, import_promises2.readFile)(schemaPath, "utf-8");
542
+ content = content.replace(/provider\s*=\s*["']postgresql["']/g, 'provider = "mongodb"');
543
+ content = content.replace(
544
+ /id\s+String\s+@id/g,
545
+ 'id String @id @default(auto()) @map("_id") @db.ObjectId'
546
+ );
547
+ await (0, import_promises2.writeFile)(schemaPath, content, "utf-8");
548
+ } catch (err) {
549
+ console.warn("Warning: Could not update schema.prisma for MongoDB:", err);
550
+ }
551
+ }
552
+ const envPath = (0, import_path2.join)(destDir, ".env.example");
553
+ if (await pathExists(envPath)) {
554
+ try {
555
+ let content = await (0, import_promises2.readFile)(envPath, "utf-8");
556
+ content = content.replace(
557
+ /DATABASE_URL=postgresql:\/\/.*/g,
558
+ 'DATABASE_URL="mongodb+srv://user:password@cluster.mongodb.net/myDatabase?retryWrites=true&w=majority"'
559
+ );
560
+ await (0, import_promises2.writeFile)(envPath, content, "utf-8");
561
+ } catch {
562
+ }
563
+ }
564
+ }
435
565
  if (!modules.includes("redis")) {
436
566
  await removeRedis(destDir);
437
567
  }
@@ -451,81 +581,224 @@ function guardCancel(value) {
451
581
  }
452
582
  return value;
453
583
  }
584
+ async function directoryExists(p) {
585
+ try {
586
+ await (0, import_promises3.stat)(p);
587
+ return true;
588
+ } catch {
589
+ return false;
590
+ }
591
+ }
454
592
  async function main() {
455
- (0, import_prompts.intro)("create-app -- NestJS DDD scaffolder");
456
- const argName = process.argv[2];
457
- let serviceName;
458
- if (argName && !validateServiceName(argName)) {
459
- serviceName = argName;
460
- console.log(` Service name : ${serviceName} (from arguments)`);
461
- } else {
462
- serviceName = guardCancel(
463
- await (0, import_prompts.text)({
464
- message: "Service name (kebab-case)",
465
- placeholder: "my-service",
466
- validate: (v) => validateServiceName(v)
467
- })
468
- );
469
- }
470
- const db = guardCancel(
471
- await (0, import_prompts.select)({
472
- message: "Select database",
473
- options: [
474
- { value: "postgres", label: "PostgreSQL", hint: "default" },
475
- { value: "mongo", label: "MongoDB" }
476
- ]
477
- })
478
- );
479
- const modules = guardCancel(
480
- await (0, import_prompts.multiselect)({
481
- message: "Optional modules (space to toggle, enter to confirm)",
482
- options: [
483
- { value: "redis", label: "Redis", hint: "caching + circuit breaker" },
484
- { value: "otel", label: "OpenTelemetry", hint: "traces + metrics" },
485
- { value: "kafka", label: "Kafka", hint: "message broker boilerplate" }
486
- ],
487
- required: false
488
- })
489
- );
490
- const selectedModules = modules.length > 0 ? modules.join(", ") : "none";
491
- console.log("");
492
- console.log(` Service name : ${serviceName}`);
493
- console.log(` Database : ${db}`);
494
- console.log(` Modules : ${selectedModules}`);
495
- console.log("");
496
- const proceed = guardCancel(
497
- await (0, import_prompts.confirm)({
498
- message: "Scaffold project with these settings?",
499
- initialValue: true
500
- })
501
- );
502
- if (!proceed) {
503
- (0, import_prompts.cancel)("Scaffolding cancelled.");
504
- process.exit(0);
593
+ const args = process.argv.slice(2);
594
+ const dryRun = args.includes("--dry-run");
595
+ const positionalName = args.find((a) => !a.startsWith("--"));
596
+ (0, import_prompts.intro)("create-app -- NestJS DDD scaffolder" + (dryRun ? " [DRY RUN]" : ""));
597
+ const config = {
598
+ serviceName: "",
599
+ db: "postgres",
600
+ orm: "mongoose",
601
+ modules: []
602
+ };
603
+ if (positionalName && !validateServiceName(positionalName)) {
604
+ config.serviceName = positionalName;
605
+ (0, import_prompts.note)(`Using service name: ${config.serviceName} (from arguments)`, "Info");
505
606
  }
506
- const destDir = (0, import_path3.join)(process.cwd(), serviceName);
607
+ let step = config.serviceName ? 1 : 0;
608
+ const totalSteps = 4;
609
+ while (step < totalSteps) {
610
+ switch (step) {
611
+ case 0: {
612
+ const res = guardCancel(
613
+ await (0, import_prompts.text)({
614
+ message: "Service name (kebab-case)",
615
+ placeholder: "my-service",
616
+ validate: (v) => validateServiceName(v)
617
+ })
618
+ );
619
+ config.serviceName = res;
620
+ step++;
621
+ break;
622
+ }
623
+ case 1: {
624
+ const res = guardCancel(
625
+ await (0, import_prompts.select)({
626
+ message: "Select database",
627
+ options: [
628
+ { value: "postgres", label: "PostgreSQL", hint: "using Prisma" },
629
+ { value: "mongo", label: "MongoDB", hint: "choice of Mongoose or Prisma" },
630
+ { value: "_back", label: "Go Back", hint: "return to service name" }
631
+ ]
632
+ })
633
+ );
634
+ if (res === "_back") {
635
+ step--;
636
+ } else {
637
+ config.db = res;
638
+ if (res === "mongo") {
639
+ config.orm = guardCancel(
640
+ await (0, import_prompts.select)({
641
+ message: "Select MongoDB ORM",
642
+ options: [
643
+ { value: "mongoose", label: "Mongoose", hint: "default for NestJS" },
644
+ { value: "prisma", label: "Prisma", hint: "v6 compatible mode" }
645
+ ]
646
+ })
647
+ );
648
+ } else {
649
+ config.orm = "prisma";
650
+ }
651
+ step++;
652
+ }
653
+ break;
654
+ }
655
+ case 2: {
656
+ const res = guardCancel(
657
+ await (0, import_prompts.multiselect)({
658
+ message: "Optional modules (space to toggle, enter to confirm)",
659
+ options: [
660
+ { value: "redis", label: "Redis", hint: "caching + circuit breaker" },
661
+ { value: "otel", label: "OpenTelemetry", hint: "traces + metrics" },
662
+ { value: "kafka", label: "Kafka", hint: "message broker boilerplate" },
663
+ { value: "_back", label: "Go Back", hint: "return to database selection" }
664
+ ],
665
+ required: false
666
+ })
667
+ );
668
+ if (res.includes("_back")) {
669
+ step--;
670
+ } else {
671
+ config.modules = res;
672
+ step++;
673
+ }
674
+ break;
675
+ }
676
+ case 3: {
677
+ const selectedModules = config.modules.length > 0 ? config.modules.join(", ") : "none";
678
+ (0, import_prompts.note)(
679
+ `Service name : ${config.serviceName}
680
+ Database : ${config.db}
681
+ ORM : ${config.orm}
682
+ Modules : ${selectedModules}`,
683
+ "Confirm Project Summary"
684
+ );
685
+ const res = guardCancel(
686
+ await (0, import_prompts.select)({
687
+ message: "Scaffold project with these settings?",
688
+ options: [
689
+ { value: "confirm", label: "Yes, scaffold project" },
690
+ { value: "back", label: "No, let me change something", hint: "go back" },
691
+ { value: "cancel", label: "Cancel", hint: "exit" }
692
+ ]
693
+ })
694
+ );
695
+ if (res === "confirm") {
696
+ const destDir2 = (0, import_path3.join)(process.cwd(), config.serviceName);
697
+ if (await directoryExists(destDir2) && !dryRun) {
698
+ const overwrite = guardCancel(
699
+ await (0, import_prompts.select)({
700
+ message: `Directory "${config.serviceName}" already exists.`,
701
+ options: [
702
+ { value: "overwrite", label: "Overwrite", hint: "danger: deletes existing directory" },
703
+ { value: "back", label: "Change service name", hint: "go back to step 1" },
704
+ { value: "cancel", label: "Exit", hint: "cancel" }
705
+ ]
706
+ })
707
+ );
708
+ if (overwrite === "overwrite") {
709
+ const confirmDelete = guardCancel(await (0, import_prompts.confirm)({ message: "Are you absolutely sure?", initialValue: false }));
710
+ if (confirmDelete) {
711
+ const s2 = (0, import_prompts.spinner)();
712
+ s2.start("Cleaning up existing directory...");
713
+ await (0, import_promises3.rm)(destDir2, { recursive: true, force: true });
714
+ s2.stop("Directory cleaned.");
715
+ step++;
716
+ }
717
+ } else if (overwrite === "back") {
718
+ step = 0;
719
+ } else {
720
+ (0, import_prompts.cancel)("Operation cancelled.");
721
+ process.exit(0);
722
+ }
723
+ } else {
724
+ step++;
725
+ }
726
+ } else if (res === "back") {
727
+ step--;
728
+ } else {
729
+ (0, import_prompts.cancel)("Scaffolding cancelled.");
730
+ process.exit(0);
731
+ }
732
+ break;
733
+ }
734
+ }
735
+ }
736
+ const destDir = (0, import_path3.join)(process.cwd(), config.serviceName);
507
737
  const s = (0, import_prompts.spinner)();
508
738
  s.start("Scaffolding project...");
509
739
  try {
510
740
  await scaffold({
511
- serviceName,
512
- db,
513
- modules,
514
- destDir
741
+ serviceName: config.serviceName,
742
+ db: config.db,
743
+ orm: config.orm,
744
+ modules: config.modules,
745
+ destDir,
746
+ dryRun
515
747
  });
516
748
  s.stop("Project scaffolded!");
517
749
  } catch (err) {
518
750
  s.stop("Scaffolding failed.");
519
751
  throw err;
520
752
  }
753
+ if (!dryRun) {
754
+ const initGit = guardCancel(await (0, import_prompts.confirm)({ message: "Initialize git repository?", initialValue: true }));
755
+ if (initGit) {
756
+ const gs = (0, import_prompts.spinner)();
757
+ gs.start("Initializing git...");
758
+ try {
759
+ await (0, import_execa.execa)("git", ["init"], { cwd: destDir });
760
+ await (0, import_execa.execa)("git", ["add", "."], { cwd: destDir });
761
+ await (0, import_execa.execa)("git", ["commit", "-m", "chore: initial commit from template"], { cwd: destDir });
762
+ gs.stop("Git initialized with initial commit.");
763
+ } catch (err) {
764
+ gs.stop("Git initialization failed (check if git is installed).");
765
+ }
766
+ }
767
+ const installDeps = guardCancel(await (0, import_prompts.confirm)({ message: "Install dependencies now?", initialValue: true }));
768
+ if (installDeps) {
769
+ const pkgManager = guardCancel(await (0, import_prompts.select)({
770
+ message: "Select package manager",
771
+ options: [
772
+ { value: "pnpm", label: "pnpm", hint: "recommended" },
773
+ { value: "npm", label: "npm" },
774
+ { value: "yarn", label: "yarn" }
775
+ ]
776
+ }));
777
+ const is = (0, import_prompts.spinner)();
778
+ is.start(`Installing dependencies using ${pkgManager}...`);
779
+ try {
780
+ await (0, import_execa.execa)(pkgManager, ["install"], { cwd: destDir, stdio: "inherit" });
781
+ is.stop("Dependencies installed successfully.");
782
+ if (config.orm === "prisma") {
783
+ const ps = (0, import_prompts.spinner)();
784
+ ps.start("Generating Prisma Client...");
785
+ try {
786
+ await (0, import_execa.execa)(pkgManager, ["run", "db:generate"], { cwd: destDir });
787
+ ps.stop("Prisma Client generated successfully.");
788
+ } catch (err) {
789
+ ps.stop("Prisma Client generation failed. You may need to run it manually.");
790
+ }
791
+ }
792
+ } catch (err) {
793
+ is.stop("Dependency installation failed.");
794
+ }
795
+ }
796
+ }
521
797
  (0, import_prompts.outro)(
522
798
  `Next steps:
523
799
 
524
- cd ${serviceName}
525
- npm install
526
- cp .env.example .env
527
- npm run start:dev
528
- `
800
+ cd ${config.serviceName}
801
+ ${dryRun ? "" : " npm run start:dev\n"}`
529
802
  );
530
803
  }
531
804
  main().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tndhuy/create-app",
3
- "version": "1.0.0",
3
+ "version": "1.2.2",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "create-app": "dist/cli.js"
@@ -0,0 +1,128 @@
1
+ # nestjs-backend-template
2
+
3
+ ## Overview
4
+
5
+ - Production-ready NestJS 11 template with Domain-Driven Design (DDD) architecture — four layers (Domain, Application, Infrastructure, Presenter) with CQRS via `@nestjs/cqrs`
6
+ - Pre-configured infrastructure: Prisma ORM, ioredis with circuit breaker, OpenTelemetry tracing and Prometheus metrics, Scalar API docs at `/docs`
7
+ - Agent-ready: Claude Code (`.claude/`) and Antigravity (`.agent/`) configs pre-wired with codebase context files and GSD skill
8
+
9
+ ## Quick Start
10
+
11
+ 1. **Clone the repository**
12
+
13
+ ```bash
14
+ git clone <repo-url> my-service && cd my-service
15
+ ```
16
+
17
+ 2. **Install dependencies**
18
+
19
+ ```bash
20
+ pnpm install
21
+ ```
22
+
23
+ 3. **Configure environment**
24
+
25
+ ```bash
26
+ cp .env.example .env
27
+ ```
28
+
29
+ Edit `.env` and set at minimum:
30
+ - `DATABASE_URL` — MongoDB connection string
31
+ - `REDIS_URL` — Redis connection string
32
+
33
+ 4. **Start infrastructure**
34
+
35
+ ```bash
36
+ docker compose up -d
37
+ ```
38
+
39
+ Starts MongoDB and Redis locally.
40
+
41
+ 5. **Start the server**
42
+
43
+ ```bash
44
+ pnpm dev
45
+ ```
46
+
47
+ Server starts at http://localhost:3000. API docs available at http://localhost:3000/docs (login: `admin` / `admin`).
48
+
49
+ ## Available Scripts
50
+
51
+ | Script | Description |
52
+ |--------|-------------|
53
+ | `pnpm start:dev` | Start in watch mode (development) |
54
+ | `pnpm build` | Compile TypeScript to `dist/` |
55
+ | `pnpm start:prod` | Run compiled build (`dist/src/main`) |
56
+ | `pnpm test` | Run unit tests with Jest |
57
+ | `pnpm test:cov` | Run tests with coverage report |
58
+ | `pnpm test:e2e` | Run end-to-end tests |
59
+ | `pnpm lint` | Run ESLint with auto-fix |
60
+ | `pnpm format` | Run Prettier on `src/` and `test/` |
61
+ | `pnpm db:generate` | Generate Prisma Client from schema |
62
+ | `pnpm db:sync` | Sync Prisma schema to MongoDB (`prisma db push`) |
63
+ | `pnpm db:sync:force` | Force sync schema with destructive changes (`--accept-data-loss`) |
64
+ | `pnpm db:push` | Push schema changes directly to database (no migration files) |
65
+ | `pnpm db:pull` | Pull database schema into `prisma/schema/` |
66
+ | `pnpm db:validate` | Validate Prisma schema and datasource config |
67
+ | `pnpm db:format` | Format Prisma schema files under `prisma/schema/` |
68
+ | `pnpm db:studio` | Open Prisma Studio for data browsing/editing |
69
+
70
+ ## MongoDB Schema Workflow
71
+
72
+ Prisma migrations are not used for MongoDB in this template.
73
+
74
+ 1. Edit schema files in `prisma/schema/`
75
+ 2. Run `pnpm db:format`
76
+ 3. Run `pnpm db:validate`
77
+ 4. Run `pnpm db:sync` (or `pnpm db:sync:force` when explicitly needed)
78
+ 5. Run `pnpm db:generate`
79
+
80
+ ## Environment Variables
81
+
82
+ | Variable | Required | Description | Default |
83
+ |----------|----------|-------------|---------|
84
+ | `PORT` | No | HTTP server port | `3000` |
85
+ | `NODE_ENV` | No | Environment (`development` / `production`) | `development` |
86
+ | `DATABASE_URL` | Yes | MongoDB connection string | — |
87
+ | `REDIS_URL` | Yes | Redis connection string | — |
88
+ | `API_VERSION` | No | API version for URI prefix | `1` |
89
+ | `APP_NAME` | No | Application name shown in Scalar docs | `NestJS Backend Template` |
90
+ | `APP_DESCRIPTION` | No | API description shown in Scalar docs | `API Documentation` |
91
+ | `REQUEST_TIMEOUT` | No | Request timeout in milliseconds | `30000` |
92
+ | `THROTTLE_TTL` | No | Rate limit window in seconds | `60` |
93
+ | `THROTTLE_LIMIT` | No | Max requests per window | `100` |
94
+ | `DOCS_USER` | No | Basic auth username for `/docs` | `admin` |
95
+ | `DOCS_PASS` | No | Basic auth password for `/docs` | `admin` |
96
+ | `OTEL_ENABLED` | No | Enable OpenTelemetry tracing and metrics | `false` |
97
+ | `OTEL_SERVICE_NAME` | No | Service name reported to OTel collector | `nestjs-backend-template` |
98
+ | `OTEL_PROMETHEUS_PORT` | No | Port for Prometheus metrics scrape endpoint | `9464` |
99
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | No | OTLP HTTP endpoint for trace export | `http://localhost:4318` |
100
+ | `LARK_APP_ID` | No | Lark app ID for MCP integration (agent use) | — |
101
+ | `LARK_APP_SECRET` | No | Lark app secret for MCP integration (agent use) | — |
102
+
103
+ ## Architecture
104
+
105
+ This template follows Domain-Driven Design (DDD) with four layers: Domain, Application, Infrastructure, and Presenter. Each domain module is fully self-contained under `src/<module>/` and communicates through the CQRS bus.
106
+
107
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full guide including layer rules, data flow, and the `ExampleModule` walkthrough.
108
+
109
+ ## Adding a New Module
110
+
111
+ Each new domain module follows the same DDD structure as `src/example/`. Follow the step-by-step walkthrough in [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md).
112
+
113
+ ## Tech Stack
114
+
115
+ - **NestJS** 11.0.1 — framework (Express 5 platform)
116
+ - **Prisma** 7.5.0 — MongoDB ORM with schema sync (`db push`)
117
+ - **ioredis** 5.10.1 — Redis client with cockatiel circuit breaker
118
+ - **Pino** / nestjs-pino — structured JSON logging
119
+ - **OpenTelemetry** SDK — distributed tracing (OTLP) and Prometheus metrics
120
+ - **Scalar** (@scalar/nestjs-api-reference) — interactive API docs
121
+ - **class-validator** / **class-transformer** — DTO validation and transformation
122
+ - **@nestjs/cqrs** 11.0.3 — command and query bus
123
+ - **@nestjs/throttler** 6.5.0 — global rate limiting
124
+ - **TypeScript** 5.7.3
125
+
126
+ ## License
127
+
128
+ UNLICENSED
@@ -1,7 +1,28 @@
1
1
  services:
2
+ {{#IF_POSTGRES}}
3
+ postgres:
4
+ image: postgres:16-alpine
5
+ container_name: {{SERVICE_NAME_KEBAB}}-postgres
6
+ restart: unless-stopped
7
+ ports:
8
+ - "5432:5432"
9
+ environment:
10
+ POSTGRES_USER: user
11
+ POSTGRES_PASSWORD: password
12
+ POSTGRES_DB: template_db
13
+ volumes:
14
+ - postgres_data:/var/lib/postgresql/data
15
+ healthcheck:
16
+ test: ["CMD-SHELL", "pg_isready -U user -d template_db"]
17
+ interval: 10s
18
+ timeout: 5s
19
+ retries: 5
20
+ {{/IF_POSTGRES}}
21
+
22
+ {{#IF_MONGO}}
2
23
  mongodb:
3
24
  image: mongo:latest
4
- container_name: nestjs-template-mongodb
25
+ container_name: {{SERVICE_NAME_KEBAB}}-mongodb
5
26
  restart: unless-stopped
6
27
  ports:
7
28
  - "27017:27017"
@@ -13,11 +34,13 @@ services:
13
34
  test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
14
35
  interval: 10s
15
36
  timeout: 5s
16
- retries: 10
37
+ retries: 5
38
+ {{/IF_MONGO}}
17
39
 
40
+ {{#IF_REDIS}}
18
41
  redis:
19
42
  image: redis:latest
20
- container_name: nestjs-template-redis
43
+ container_name: {{SERVICE_NAME_KEBAB}}-redis
21
44
  restart: unless-stopped
22
45
  ports:
23
46
  - "6379:6379"
@@ -28,8 +51,57 @@ services:
28
51
  test: ["CMD", "redis-cli", "ping"]
29
52
  interval: 10s
30
53
  timeout: 5s
31
- retries: 10
54
+ retries: 5
55
+ {{/IF_REDIS}}
56
+
57
+ {{#IF_KAFKA}}
58
+ zookeeper:
59
+ image: bitnami/zookeeper:latest
60
+ container_name: {{SERVICE_NAME_KEBAB}}-zookeeper
61
+ ports:
62
+ - "2181:2181"
63
+ environment:
64
+ - ALLOW_ANONYMOUS_LOGIN=yes
65
+
66
+ kafka:
67
+ image: bitnami/kafka:latest
68
+ container_name: {{SERVICE_NAME_KEBAB}}-kafka
69
+ ports:
70
+ - "9092:9092"
71
+ environment:
72
+ - KAFKA_BROKER_ID=1
73
+ - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
74
+ - ALLOW_PLAINTEXT_LISTENER=yes
75
+ - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
76
+ depends_on:
77
+ - zookeeper
78
+ {{/IF_KAFKA}}
79
+
80
+ {{#IF_OTEL}}
81
+ jaeger:
82
+ image: jaegertracing/all-in-one:latest
83
+ container_name: {{SERVICE_NAME_KEBAB}}-jaeger
84
+ ports:
85
+ - "16686:16686"
86
+ - "4317:4317"
87
+ - "4318:4318"
88
+
89
+ prometheus:
90
+ image: prom/prometheus:latest
91
+ container_name: {{SERVICE_NAME_KEBAB}}-prometheus
92
+ ports:
93
+ - "9090:9090"
94
+ volumes:
95
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
96
+ {{/IF_OTEL}}
32
97
 
33
98
  volumes:
99
+ {{#IF_POSTGRES}}
100
+ postgres_data:
101
+ {{/IF_POSTGRES}}
102
+ {{#IF_MONGO}}
34
103
  mongodb_data:
104
+ {{/IF_MONGO}}
105
+ {{#IF_REDIS}}
35
106
  redis_data:
107
+ {{/IF_REDIS}}
@@ -0,0 +1,7 @@
1
+ global:
2
+ scrape_interval: 15s
3
+
4
+ scrape_configs:
5
+ - job_name: 'nestjs-service'
6
+ static_configs:
7
+ - targets: ['host.docker.internal:9464']
@@ -4,11 +4,22 @@ import { Observable } from 'rxjs';
4
4
  import { map } from 'rxjs/operators';
5
5
  import { PUBLIC_API_KEY } from '../decorators/public-api.decorator';
6
6
 
7
+ export const RAW_RESPONSE_KEY = 'raw_response';
8
+
7
9
  @Injectable()
8
10
  export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
9
11
  constructor(private readonly reflector: Reflector) {}
10
12
 
11
13
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
14
+ const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE_KEY, [
15
+ context.getHandler(),
16
+ context.getClass(),
17
+ ]);
18
+
19
+ if (isRaw) {
20
+ return next.handle();
21
+ }
22
+
12
23
  const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_API_KEY, [
13
24
  context.getHandler(),
14
25
  context.getClass(),
@@ -87,7 +87,11 @@ async function bootstrap() {
87
87
  process.on('SIGTERM', () => {
88
88
  loggerService.log('SIGTERM signal received: closing HTTP server');
89
89
  setTimeout(() => {
90
- void otelSdk?.shutdown().catch(() => undefined);
90
+ if (process.env.OTEL_ENABLED === 'true') {
91
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
92
+ const otelSdk = require('./instrumentation').default;
93
+ void otelSdk?.shutdown().catch(() => undefined);
94
+ }
91
95
  httpServer.close(() => {
92
96
  loggerService.log('HTTP server closed');
93
97
  process.exit(0);
@@ -0,0 +1,76 @@
1
+ # {{SERVICE_NAME_KEBAB}}
2
+
3
+ Production-ready NestJS 11 backend service based on DDD architecture.
4
+
5
+ ## Architecture Overview
6
+
7
+ This project follows **Domain-Driven Design (DDD)** principles with four distinct layers:
8
+
9
+ 1. **Domain:** Core logic, Entities, Value Objects, and Repository interfaces. (No framework dependencies)
10
+ 2. **Application:** Command/Query handlers (CQRS), DTOs, and application services.
11
+ 3. **Infrastructure:** Database implementations (Prisma/Mongoose), Redis cache, and external integrations.
12
+ 4. **Presenter:** Controllers (HTTP/RPC), Interceptors, and Filters.
13
+
14
+ ## Features
15
+
16
+ - **Pino Logging:** High-performance logging with request context tracking.
17
+ - **Request ID:** Every request is assigned a unique `X-Request-Id` header for traceability.
18
+ - **OpenTelemetry:** Distributed tracing and Prometheus metrics (if enabled).
19
+ - **Circuit Breaker:** Resilience for Redis and external calls using Cockatiel.
20
+ - **API Reference:** Interactive Scalar documentation available at `/docs`.
21
+
22
+ ## Quick Start
23
+
24
+ ### 1. Configure Environment
25
+
26
+ ```bash
27
+ cp .env.example .env
28
+ ```
29
+
30
+ ### 2. Infrastructure
31
+
32
+ Start database and cache using Docker:
33
+
34
+ ```bash
35
+ docker compose up -d
36
+ ```
37
+
38
+ ### 3. Database Setup
39
+
40
+ {{#IF_PRISMA}}
41
+ ```bash
42
+ # Generate Prisma Client
43
+ npm run db:generate
44
+
45
+ # Apply migrations
46
+ npm run db:migrate:deploy
47
+ ```
48
+ {{/IF_PRISMA}}
49
+ {{#IF_MONGOOSE}}
50
+ Ensure your `MONGODB_URL` is correct in `.env`.
51
+ {{/IF_MONGOOSE}}
52
+
53
+ ### 4. Running the App
54
+
55
+ ```bash
56
+ # Development
57
+ npm run start:dev
58
+
59
+ # Production build
60
+ npm run build
61
+ npm run start:prod
62
+ ```
63
+
64
+ ## Logging & Observability
65
+
66
+ - **Console:** Pretty-printed logs in development mode.
67
+ - **Files:** Logs are automatically rotated and stored in the `logs/` directory.
68
+ - **Correlation:** Search for `requestId` in logs to trace all logs for a specific user request.
69
+
70
+ ## Available Scripts
71
+
72
+ - `npm run build`: Compile the project.
73
+ - `npm run start:dev`: Start in watch mode.
74
+ - `npm run test`: Run unit tests.
75
+ - `npm run test:e2e`: Run end-to-end tests.
76
+ - `npm run lint`: Fix code style issues.
@@ -0,0 +1,107 @@
1
+ services:
2
+ {{#IF_POSTGRES}}
3
+ postgres:
4
+ image: postgres:16-alpine
5
+ container_name: {{SERVICE_NAME_KEBAB}}-postgres
6
+ restart: unless-stopped
7
+ ports:
8
+ - "5432:5432"
9
+ environment:
10
+ POSTGRES_USER: user
11
+ POSTGRES_PASSWORD: password
12
+ POSTGRES_DB: template_db
13
+ volumes:
14
+ - postgres_data:/var/lib/postgresql/data
15
+ healthcheck:
16
+ test: ["CMD-SHELL", "pg_isready -U user -d template_db"]
17
+ interval: 10s
18
+ timeout: 5s
19
+ retries: 5
20
+ {{/IF_POSTGRES}}
21
+
22
+ {{#IF_MONGO}}
23
+ mongodb:
24
+ image: mongo:latest
25
+ container_name: {{SERVICE_NAME_KEBAB}}-mongodb
26
+ restart: unless-stopped
27
+ ports:
28
+ - "27017:27017"
29
+ environment:
30
+ MONGO_INITDB_DATABASE: template_db
31
+ volumes:
32
+ - mongodb_data:/data/db
33
+ healthcheck:
34
+ test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
35
+ interval: 10s
36
+ timeout: 5s
37
+ retries: 5
38
+ {{/IF_MONGO}}
39
+
40
+ {{#IF_REDIS}}
41
+ redis:
42
+ image: redis:latest
43
+ container_name: {{SERVICE_NAME_KEBAB}}-redis
44
+ restart: unless-stopped
45
+ ports:
46
+ - "6379:6379"
47
+ command: ["redis-server", "--appendonly", "yes"]
48
+ volumes:
49
+ - redis_data:/data
50
+ healthcheck:
51
+ test: ["CMD", "redis-cli", "ping"]
52
+ interval: 10s
53
+ timeout: 5s
54
+ retries: 5
55
+ {{/IF_REDIS}}
56
+
57
+ {{#IF_KAFKA}}
58
+ zookeeper:
59
+ image: bitnami/zookeeper:latest
60
+ container_name: {{SERVICE_NAME_KEBAB}}-zookeeper
61
+ ports:
62
+ - "2181:2181"
63
+ environment:
64
+ - ALLOW_ANONYMOUS_LOGIN=yes
65
+
66
+ kafka:
67
+ image: bitnami/kafka:latest
68
+ container_name: {{SERVICE_NAME_KEBAB}}-kafka
69
+ ports:
70
+ - "9092:9092"
71
+ environment:
72
+ - KAFKA_BROKER_ID=1
73
+ - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
74
+ - ALLOW_PLAINTEXT_LISTENER=yes
75
+ - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092
76
+ depends_on:
77
+ - zookeeper
78
+ {{/IF_KAFKA}}
79
+
80
+ {{#IF_OTEL}}
81
+ jaeger:
82
+ image: jaegertracing/all-in-one:latest
83
+ container_name: {{SERVICE_NAME_KEBAB}}-jaeger
84
+ ports:
85
+ - "16686:16686"
86
+ - "4317:4317"
87
+ - "4318:4318"
88
+
89
+ prometheus:
90
+ image: prom/prometheus:latest
91
+ container_name: {{SERVICE_NAME_KEBAB}}-prometheus
92
+ ports:
93
+ - "9090:9090"
94
+ volumes:
95
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
96
+ {{/IF_OTEL}}
97
+
98
+ volumes:
99
+ {{#IF_POSTGRES}}
100
+ postgres_data:
101
+ {{/IF_POSTGRES}}
102
+ {{#IF_MONGO}}
103
+ mongodb_data:
104
+ {{/IF_MONGO}}
105
+ {{#IF_REDIS}}
106
+ redis_data:
107
+ {{/IF_REDIS}}
@@ -4,10 +4,7 @@
4
4
  "description": "",
5
5
  "author": "",
6
6
  "private": true,
7
- "license": "UNLICENSED",
8
- "workspaces": [
9
- "packages/*"
10
- ],
7
+ "license": "MIT",
11
8
  "scripts": {
12
9
  "build": "nest build",
13
10
  "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
@@ -63,7 +60,6 @@
63
60
  "@nestjs/schematics": "^11.0.0",
64
61
  "@nestjs/testing": "^11.0.1",
65
62
  "@types/express": "^5.0.0",
66
- "@types/ioredis": "^5.0.0",
67
63
  "@types/jest": "^30.0.0",
68
64
  "@types/node": "^22.10.7",
69
65
  "@types/supertest": "^6.0.2",
@@ -4,6 +4,7 @@ generator client {
4
4
 
5
5
  datasource db {
6
6
  provider = "postgresql"
7
+ url = env("DATABASE_URL")
7
8
  }
8
9
 
9
10
  model Item {
@@ -0,0 +1,7 @@
1
+ global:
2
+ scrape_interval: 15s
3
+
4
+ scrape_configs:
5
+ - job_name: 'nestjs-service'
6
+ static_configs:
7
+ - targets: ['host.docker.internal:9464']
@@ -4,11 +4,22 @@ import { Observable } from 'rxjs';
4
4
  import { map } from 'rxjs/operators';
5
5
  import { PUBLIC_API_KEY } from '../decorators/public-api.decorator';
6
6
 
7
+ export const RAW_RESPONSE_KEY = 'raw_response';
8
+
7
9
  @Injectable()
8
10
  export class TransformInterceptor<T> implements NestInterceptor<T, unknown> {
9
11
  constructor(private readonly reflector: Reflector) {}
10
12
 
11
13
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
14
+ const isRaw = this.reflector.getAllAndOverride<boolean>(RAW_RESPONSE_KEY, [
15
+ context.getHandler(),
16
+ context.getClass(),
17
+ ]);
18
+
19
+ if (isRaw) {
20
+ return next.handle();
21
+ }
22
+
12
23
  const isPublic = this.reflector.getAllAndOverride<boolean>(PUBLIC_API_KEY, [
13
24
  context.getHandler(),
14
25
  context.getClass(),
@@ -87,7 +87,11 @@ async function bootstrap() {
87
87
  process.on('SIGTERM', () => {
88
88
  loggerService.log('SIGTERM signal received: closing HTTP server');
89
89
  setTimeout(() => {
90
- void otelSdk?.shutdown().catch(() => undefined);
90
+ if (process.env.OTEL_ENABLED === 'true') {
91
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
92
+ const otelSdk = require('./instrumentation').default;
93
+ void otelSdk?.shutdown().catch(() => undefined);
94
+ }
91
95
  httpServer.close(() => {
92
96
  loggerService.log('HTTP server closed');
93
97
  process.exit(0);