@yoms/create-monorepo 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,7 +6,9 @@ import { cac } from "cac";
6
6
  // src/commands/create.ts
7
7
  import path7 from "path";
8
8
  import ora from "ora";
9
+ import chalk from "chalk";
9
10
  import { execa as execa2 } from "execa";
11
+ import enquirer4 from "enquirer";
10
12
 
11
13
  // src/utils/file-ops.ts
12
14
  import fs from "fs-extra";
@@ -74,6 +76,15 @@ var logger = {
74
76
 
75
77
  // src/utils/package-manager.ts
76
78
  import { execa } from "execa";
79
+ async function detectPackageManager() {
80
+ const userAgent = process.env.npm_config_user_agent;
81
+ if (userAgent) {
82
+ if (userAgent.includes("pnpm")) return "pnpm";
83
+ if (userAgent.includes("yarn")) return "yarn";
84
+ if (userAgent.includes("bun")) return "bun";
85
+ }
86
+ return "pnpm";
87
+ }
77
88
  async function installDependencies(cwd, pm) {
78
89
  await execa(pm, ["install"], { cwd, stdio: "inherit" });
79
90
  }
@@ -187,13 +198,24 @@ async function promptBackendConfig() {
187
198
  });
188
199
  includeSwagger = swagger;
189
200
  }
201
+ let includeAuth = false;
202
+ if (backendType === "web") {
203
+ const { auth } = await enquirer2.prompt({
204
+ type: "confirm",
205
+ name: "auth",
206
+ message: "Include JWT authentication?",
207
+ initial: false
208
+ });
209
+ includeAuth = auth;
210
+ }
190
211
  return {
191
212
  type: backendType,
192
213
  framework,
193
214
  database: database === "none" ? void 0 : database,
194
215
  includeRedis,
195
216
  includeSmtp,
196
- includeSwagger
217
+ includeSwagger,
218
+ includeAuth
197
219
  };
198
220
  }
199
221
 
@@ -230,6 +252,7 @@ async function promptFrontendConfig() {
230
252
 
231
253
  // src/generators/monorepo.generator.ts
232
254
  import path2 from "path";
255
+ import { fileURLToPath } from "url";
233
256
 
234
257
  // src/generators/base.generator.ts
235
258
  var BaseGenerator = class {
@@ -254,11 +277,19 @@ var BaseGenerator = class {
254
277
  };
255
278
 
256
279
  // src/generators/monorepo.generator.ts
280
+ var __dirname = path2.dirname(fileURLToPath(import.meta.url));
281
+ var getPackageRoot = () => {
282
+ const dirname = __dirname;
283
+ return path2.resolve(dirname, "..");
284
+ };
257
285
  var MonorepoGenerator = class extends BaseGenerator {
258
286
  async generate(config) {
259
287
  await ensureDir(this.options.projectDir);
260
288
  await this.createRootFiles(config);
261
289
  await this.createPackagesDirectory();
290
+ if (config.includeDocker) {
291
+ await this.createDockerCompose(config);
292
+ }
262
293
  }
263
294
  async createRootFiles(config) {
264
295
  const workspaceContent = `packages:
@@ -393,11 +424,34 @@ ${config.includeBackend ? "\u2502 \u251C\u2500\u2500 api/ # Backend AP
393
424
  async createPackagesDirectory() {
394
425
  await ensureDir(path2.join(this.options.projectDir, "packages"));
395
426
  }
427
+ async createDockerCompose(config) {
428
+ const packageRoot = getPackageRoot();
429
+ const templatePath = path2.join(packageRoot, "templates", "docker-compose.yml");
430
+ const template = await readFile(templatePath);
431
+ const hasPostgres = config.backend?.database === "postgres";
432
+ const hasMongodb = config.backend?.database === "mongodb";
433
+ const hasRedis = config.backend?.includeRedis || false;
434
+ const hasSmtp = config.backend?.includeSmtp || false;
435
+ let content = template.split("\n").filter((line) => {
436
+ if (line.includes("__IF_POSTGRES__")) return hasPostgres;
437
+ if (line.includes("__IF_MONGODB__")) return hasMongodb;
438
+ if (line.includes("__IF_REDIS__")) return hasRedis;
439
+ if (line.includes("__IF_SMTP__")) return hasSmtp;
440
+ return true;
441
+ }).map((line) => {
442
+ return line.replace(/__IF_POSTGRES__/g, "").replace(/__IF_MONGODB__/g, "").replace(/__IF_REDIS__/g, "").replace(/__IF_SMTP__/g, "");
443
+ }).join("\n");
444
+ const tokens = this.getTokens();
445
+ for (const [token, value] of Object.entries(tokens)) {
446
+ content = content.replace(new RegExp(token, "g"), value);
447
+ }
448
+ await writeFile(path2.join(this.options.projectDir, "docker-compose.yml"), content);
449
+ }
396
450
  };
397
451
 
398
452
  // src/generators/backend.generator.ts
399
453
  import path4 from "path";
400
- import { fileURLToPath } from "url";
454
+ import { fileURLToPath as fileURLToPath2 } from "url";
401
455
 
402
456
  // src/utils/template-merger.ts
403
457
  import fs2 from "fs-extra";
@@ -459,9 +513,9 @@ async function mergeEnvFile(baseDir, additionsPath) {
459
513
  }
460
514
 
461
515
  // src/generators/backend.generator.ts
462
- var __dirname = path4.dirname(fileURLToPath(import.meta.url));
463
- var getPackageRoot = () => {
464
- const dirname = __dirname;
516
+ var __dirname2 = path4.dirname(fileURLToPath2(import.meta.url));
517
+ var getPackageRoot2 = () => {
518
+ const dirname = __dirname2;
465
519
  return path4.resolve(dirname, "..");
466
520
  };
467
521
  var BackendGenerator = class extends BaseGenerator {
@@ -479,7 +533,7 @@ var BackendGenerator = class extends BaseGenerator {
479
533
  __HAS_REDIS__: String(this.config.includeRedis),
480
534
  __HAS_SMTP__: String(this.config.includeSmtp)
481
535
  });
482
- const packageRoot = getPackageRoot();
536
+ const packageRoot = getPackageRoot2();
483
537
  let templatePath;
484
538
  if (this.config.type === "web") {
485
539
  templatePath = path4.join(packageRoot, "templates", `backend-${this.config.framework}`, "base");
@@ -507,16 +561,20 @@ var BackendGenerator = class extends BaseGenerator {
507
561
  const swaggerFeaturePath = path4.join(featuresPath, "swagger");
508
562
  await mergeFeature(backendDir, swaggerFeaturePath, tokens);
509
563
  }
564
+ if (this.config.includeAuth) {
565
+ const authFeaturePath = path4.join(featuresPath, "jwt-auth");
566
+ await mergeFeature(backendDir, authFeaturePath, tokens);
567
+ }
510
568
  }
511
569
  }
512
570
  };
513
571
 
514
572
  // src/generators/frontend.generator.ts
515
573
  import path5 from "path";
516
- import { fileURLToPath as fileURLToPath2 } from "url";
517
- var __dirname2 = path5.dirname(fileURLToPath2(import.meta.url));
518
- var getPackageRoot2 = () => {
519
- const dirname = __dirname2;
574
+ import { fileURLToPath as fileURLToPath3 } from "url";
575
+ var __dirname3 = path5.dirname(fileURLToPath3(import.meta.url));
576
+ var getPackageRoot3 = () => {
577
+ const dirname = __dirname3;
520
578
  return path5.resolve(dirname, "..");
521
579
  };
522
580
  var FrontendGenerator = class extends BaseGenerator {
@@ -531,7 +589,7 @@ var FrontendGenerator = class extends BaseGenerator {
531
589
  const tokens = this.getTokens({
532
590
  __API_URL__: this.config.apiUrl
533
591
  });
534
- const packageRoot = getPackageRoot2();
592
+ const packageRoot = getPackageRoot3();
535
593
  const templatePath = path5.join(packageRoot, "templates", "frontend-nextjs", "base");
536
594
  await copyDirRecursive(templatePath, frontendDir, tokens);
537
595
  }
@@ -539,10 +597,10 @@ var FrontendGenerator = class extends BaseGenerator {
539
597
 
540
598
  // src/generators/shared.generator.ts
541
599
  import path6 from "path";
542
- import { fileURLToPath as fileURLToPath3 } from "url";
543
- var __dirname3 = path6.dirname(fileURLToPath3(import.meta.url));
544
- var getPackageRoot3 = () => {
545
- const dirname = __dirname3;
600
+ import { fileURLToPath as fileURLToPath4 } from "url";
601
+ var __dirname4 = path6.dirname(fileURLToPath4(import.meta.url));
602
+ var getPackageRoot4 = () => {
603
+ const dirname = __dirname4;
546
604
  return path6.resolve(dirname, "..");
547
605
  };
548
606
  var SharedGenerator = class extends BaseGenerator {
@@ -550,57 +608,151 @@ var SharedGenerator = class extends BaseGenerator {
550
608
  const sharedDir = path6.join(this.options.projectDir, "packages", "shared");
551
609
  await ensureDir(sharedDir);
552
610
  const tokens = this.getTokens();
553
- const packageRoot = getPackageRoot3();
611
+ const packageRoot = getPackageRoot4();
554
612
  const templatePath = path6.join(packageRoot, "templates", "shared", "base");
555
613
  await copyDirRecursive(templatePath, sharedDir, tokens);
556
614
  }
557
615
  };
558
616
 
559
617
  // src/commands/create.ts
560
- async function createMonorepo(targetDir) {
618
+ function showPreview(config) {
561
619
  console.log("");
562
- logger.info("Welcome to create-monorepo!");
620
+ console.log(chalk.bold("\u{1F4E6} Project Preview:"));
563
621
  console.log("");
564
- const projectName = await promptProjectName(targetDir ? path7.basename(targetDir) : void 0);
565
- const projectDir = path7.resolve(process.cwd(), targetDir || projectName);
566
- if (!await isDirEmpty(projectDir)) {
567
- logger.error(`Directory "${projectDir}" is not empty. Please choose an empty directory.`);
568
- process.exit(1);
622
+ console.log(chalk.cyan(`${config.projectName}/`));
623
+ console.log(chalk.gray("\u251C\u2500\u2500 packages/"));
624
+ if (config.includeBackend && config.backend) {
625
+ const features = [];
626
+ if (config.backend.database) features.push(config.backend.database.toUpperCase());
627
+ if (config.backend.includeRedis) features.push("Redis");
628
+ if (config.backend.includeSmtp) features.push("SMTP");
629
+ if (config.backend.includeSwagger) features.push("Swagger");
630
+ if (config.backend.includeAuth) features.push("JWT Auth");
631
+ const featureStr = features.length > 0 ? chalk.gray(` (${features.join(", ")})`) : "";
632
+ console.log(chalk.gray(`\u2502 \u251C\u2500\u2500 api/`) + chalk.yellow(` ${config.backend.framework}`) + featureStr);
569
633
  }
570
- const packageManager = await promptPackageManager();
571
- const includeBackend = await promptIncludeBackend();
572
- const includeFrontend = await promptIncludeFrontend();
573
- if (!includeBackend && !includeFrontend) {
574
- logger.error("You must include at least one package (backend or frontend).");
575
- process.exit(1);
634
+ if (config.includeFrontend && config.frontend) {
635
+ const features = [];
636
+ if (config.frontend.includeShadcn) features.push("shadcn/ui");
637
+ const featureStr = features.length > 0 ? chalk.gray(` (${features.join(", ")})`) : "";
638
+ console.log(chalk.gray(`\u2502 \u251C\u2500\u2500 web/`) + chalk.yellow(` Next.js 15`) + featureStr);
576
639
  }
577
- const config = {
578
- projectName,
579
- packageManager,
580
- includeBackend,
581
- includeFrontend
582
- };
583
- if (includeBackend) {
584
- console.log("");
585
- logger.info("Configure backend package:");
586
- config.backend = await promptBackendConfig();
640
+ if (config.includeBackend && config.includeFrontend) {
641
+ console.log(chalk.gray(`\u2502 \u2514\u2500\u2500 shared/`) + chalk.yellow(` Zod schemas + types`));
587
642
  }
588
- if (includeFrontend) {
589
- console.log("");
590
- logger.info("Configure frontend package:");
591
- config.frontend = await promptFrontendConfig();
643
+ console.log(chalk.gray("\u251C\u2500\u2500 pnpm-workspace.yaml"));
644
+ console.log(chalk.gray("\u251C\u2500\u2500 package.json"));
645
+ if (config.includeDocker) {
646
+ console.log(chalk.gray("\u251C\u2500\u2500 docker-compose.yml") + chalk.yellow(" \u26A1"));
592
647
  }
648
+ console.log(chalk.gray("\u2514\u2500\u2500 ..."));
649
+ console.log("");
650
+ }
651
+ async function confirmPreview() {
652
+ const { confirm } = await enquirer4.prompt({
653
+ type: "confirm",
654
+ name: "confirm",
655
+ message: "Does this look good?",
656
+ initial: true
657
+ });
658
+ return confirm;
659
+ }
660
+ async function createMonorepo(targetDir, options = {}) {
593
661
  console.log("");
594
- logger.info("Generating project...");
662
+ logger.info("Welcome to create-monorepo!");
663
+ console.log("");
664
+ const isInteractive = !options.yes && process.stdin.isTTY;
595
665
  try {
666
+ const projectName = isInteractive ? await promptProjectName(targetDir ? path7.basename(targetDir) : void 0) : targetDir ? path7.basename(targetDir) : "my-monorepo";
667
+ const projectDir = path7.resolve(process.cwd(), targetDir || projectName);
668
+ if (!await isDirEmpty(projectDir)) {
669
+ throw new Error(`Directory "${projectDir}" is not empty. Please choose an empty directory.`);
670
+ }
671
+ let packageManager;
672
+ if (options.pm) {
673
+ packageManager = options.pm;
674
+ } else if (isInteractive) {
675
+ packageManager = await promptPackageManager();
676
+ } else {
677
+ packageManager = await detectPackageManager();
678
+ }
679
+ let includeBackend;
680
+ let includeFrontend;
681
+ if (options.yes) {
682
+ includeBackend = options.backend !== void 0 || options.database !== void 0;
683
+ includeFrontend = options.frontend === true;
684
+ if (!includeBackend && !includeFrontend) {
685
+ includeBackend = true;
686
+ includeFrontend = true;
687
+ }
688
+ } else if (isInteractive) {
689
+ includeBackend = await promptIncludeBackend();
690
+ includeFrontend = await promptIncludeFrontend();
691
+ } else {
692
+ includeBackend = true;
693
+ includeFrontend = true;
694
+ }
695
+ if (!includeBackend && !includeFrontend) {
696
+ throw new Error("You must include at least one package (backend or frontend).");
697
+ }
698
+ const config = {
699
+ projectName,
700
+ packageManager,
701
+ includeBackend,
702
+ includeFrontend,
703
+ includeDocker: options.docker
704
+ };
705
+ if (includeBackend) {
706
+ if (options.yes || !isInteractive) {
707
+ config.backend = {
708
+ type: "web",
709
+ framework: options.backend || "hono",
710
+ database: options.database,
711
+ includeRedis: options.redis || false,
712
+ includeSmtp: options.smtp || false,
713
+ includeSwagger: options.swagger || false,
714
+ includeAuth: options.auth || false
715
+ };
716
+ } else {
717
+ console.log("");
718
+ logger.info("Configure backend package:");
719
+ config.backend = await promptBackendConfig();
720
+ }
721
+ }
722
+ if (includeFrontend) {
723
+ if (options.yes || !isInteractive) {
724
+ config.frontend = {
725
+ framework: "nextjs",
726
+ includeShadcn: options.shadcn || false,
727
+ apiUrl: "http://localhost:3001"
728
+ };
729
+ } else {
730
+ console.log("");
731
+ logger.info("Configure frontend package:");
732
+ config.frontend = await promptFrontendConfig();
733
+ }
734
+ }
735
+ if (isInteractive) {
736
+ showPreview(config);
737
+ const confirmed = await confirmPreview();
738
+ if (!confirmed) {
739
+ console.log("");
740
+ logger.info("Cancelled. No files were created.");
741
+ return;
742
+ }
743
+ }
744
+ console.log("");
745
+ logger.info("Generating project...");
746
+ console.log("");
747
+ const spinner = ora();
748
+ spinner.start("Creating monorepo structure...");
596
749
  const monorepoGenerator = new MonorepoGenerator({
597
750
  projectName,
598
751
  projectDir,
599
752
  packageManager
600
753
  });
601
- const spinner = ora("Creating monorepo structure...").start();
602
754
  await monorepoGenerator.generate(config);
603
- spinner.succeed("Monorepo structure created");
755
+ spinner.succeed(chalk.green("Monorepo structure created"));
604
756
  if (includeBackend && includeFrontend) {
605
757
  spinner.start("Generating shared package...");
606
758
  const sharedGenerator = new SharedGenerator({
@@ -609,7 +761,7 @@ async function createMonorepo(targetDir) {
609
761
  packageManager
610
762
  });
611
763
  await sharedGenerator.generate();
612
- spinner.succeed("Shared package generated");
764
+ spinner.succeed(chalk.green("Shared package generated"));
613
765
  }
614
766
  if (includeBackend && config.backend) {
615
767
  spinner.start("Generating backend package...");
@@ -620,7 +772,7 @@ async function createMonorepo(targetDir) {
620
772
  config: config.backend
621
773
  });
622
774
  await backendGenerator.generate();
623
- spinner.succeed("Backend package generated");
775
+ spinner.succeed(chalk.green(`Backend package generated (${config.backend.framework})`));
624
776
  }
625
777
  if (includeFrontend && config.frontend) {
626
778
  spinner.start("Generating frontend package...");
@@ -631,34 +783,61 @@ async function createMonorepo(targetDir) {
631
783
  config: config.frontend
632
784
  });
633
785
  await frontendGenerator.generate();
634
- spinner.succeed("Frontend package generated");
786
+ spinner.succeed(chalk.green("Frontend package generated (Next.js 15)"));
787
+ }
788
+ if (!options.skipInstall) {
789
+ spinner.start(`Installing dependencies with ${packageManager}...`);
790
+ spinner.text = `Installing dependencies... ${chalk.gray("(this may take a minute)")}`;
791
+ await installDependencies(projectDir, packageManager);
792
+ spinner.succeed(chalk.green("Dependencies installed"));
793
+ } else {
794
+ logger.info(chalk.yellow("\u26A0 Skipped dependency installation"));
795
+ }
796
+ if (!options.skipGit) {
797
+ spinner.start("Initializing git repository...");
798
+ await execa2("git", ["init"], { cwd: projectDir });
799
+ await execa2("git", ["add", "."], { cwd: projectDir });
800
+ await execa2("git", ["commit", "-m", "Initial commit from create-monorepo"], { cwd: projectDir });
801
+ spinner.succeed(chalk.green("Git repository initialized"));
802
+ } else {
803
+ logger.info(chalk.yellow("\u26A0 Skipped git initialization"));
635
804
  }
636
- spinner.start("Installing dependencies...");
637
- await installDependencies(projectDir, packageManager);
638
- spinner.succeed("Dependencies installed");
639
- spinner.start("Initializing git repository...");
640
- await execa2("git", ["init"], { cwd: projectDir });
641
- await execa2("git", ["add", "."], { cwd: projectDir });
642
- await execa2("git", ["commit", "-m", "Initial commit from create-monorepo"], { cwd: projectDir });
643
- spinner.succeed("Git repository initialized");
644
805
  console.log("");
645
- logger.success(`Project "${projectName}" created successfully!`);
806
+ logger.success(`${chalk.bold('\u2728 Project "')}${chalk.bold.cyan(projectName)}${chalk.bold('" created successfully!')}`);
646
807
  console.log("");
647
- logger.info("Next steps:");
648
- console.log(` cd ${targetDir || projectName}`);
649
- console.log(` ${packageManager} dev`);
808
+ logger.info(chalk.bold("Next steps:"));
809
+ console.log(chalk.gray(" $ ") + chalk.cyan(`cd ${targetDir || projectName}`));
810
+ if (options.skipInstall) {
811
+ console.log(chalk.gray(" $ ") + chalk.cyan(`${packageManager} install`));
812
+ }
813
+ console.log(chalk.gray(" $ ") + chalk.cyan(`${packageManager} dev`));
650
814
  console.log("");
815
+ if (config.includeDocker) {
816
+ logger.info(chalk.bold("\u{1F433} Docker Compose:"));
817
+ console.log(chalk.gray(" $ ") + chalk.cyan("docker-compose up -d"));
818
+ console.log("");
819
+ }
651
820
  } catch (error) {
652
- logger.error(`Failed to create project: ${error instanceof Error ? error.message : String(error)}`);
821
+ console.log("");
822
+ if (error instanceof Error) {
823
+ logger.error(chalk.red(`\u2716 ${error.message}`));
824
+ if (error.stack && process.env.DEBUG) {
825
+ console.log("");
826
+ console.log(chalk.gray(error.stack));
827
+ }
828
+ } else {
829
+ logger.error(chalk.red(`\u2716 Failed to create project: ${String(error)}`));
830
+ }
831
+ console.log("");
653
832
  process.exit(1);
654
833
  }
655
834
  }
656
835
 
657
836
  // src/index.ts
658
837
  var cli = cac("create-monorepo");
659
- cli.command("[dir]", "Create a new monorepo project").action(async (dir) => {
660
- await createMonorepo(dir);
838
+ cli.command("[dir]", "Create a new monorepo project").option("--backend <framework>", "Backend framework (hono)").option("--database <db>", "Database (postgres, mongodb, none)").option("--redis", "Include Redis cache").option("--smtp", "Include SMTP email").option("--swagger", "Include Swagger docs").option("--auth", "Include JWT authentication").option("--frontend", "Include Next.js frontend").option("--shadcn", "Include shadcn/ui components").option("--pm <manager>", "Package manager (pnpm, npm, yarn, bun)").option("--docker", "Include Docker Compose setup").option("--skip-install", "Skip dependency installation").option("--skip-git", "Skip git initialization").option("-y, --yes", "Skip all prompts and use defaults").action(async (dir, options) => {
839
+ await createMonorepo(dir, options);
661
840
  });
662
841
  cli.help();
663
- cli.version("0.1.0");
842
+ cli.version("1.2.0");
664
843
  cli.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yoms/create-monorepo",
3
- "version": "1.2.0",
3
+ "version": "2.0.0",
4
4
  "description": "CLI tool to scaffold monorepo projects from templates",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,8 @@
53
53
  "prettier": "^3.4.2",
54
54
  "tsup": "^8.3.5",
55
55
  "tsx": "^4.19.2",
56
- "typescript": "^5.7.2"
56
+ "typescript": "^5.7.2",
57
+ "vitest": "^2.1.8"
57
58
  },
58
59
  "engines": {
59
60
  "node": ">=18.0.0"
@@ -64,6 +65,9 @@
64
65
  "typecheck": "tsc --noEmit",
65
66
  "lint": "eslint src --ext .ts",
66
67
  "format": "prettier --write \"src/**/*.ts\"",
68
+ "test": "vitest run",
69
+ "test:watch": "vitest",
70
+ "test:e2e": "vitest run tests/e2e",
67
71
  "test:generate": "tsx scripts/test-generate.ts",
68
72
  "test:validate": "tsx scripts/validate-templates.ts",
69
73
  "changeset": "changeset",
@@ -0,0 +1,5 @@
1
+ # JWT Configuration
2
+ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
3
+ JWT_EXPIRES_IN=15m
4
+ JWT_REFRESH_SECRET=your-super-secret-refresh-key-change-this-in-production
5
+ JWT_REFRESH_EXPIRES_IN=7d
@@ -0,0 +1,10 @@
1
+ {
2
+ "dependencies": {
3
+ "jsonwebtoken": "^9.0.2",
4
+ "bcryptjs": "^2.4.3"
5
+ },
6
+ "devDependencies": {
7
+ "@types/jsonwebtoken": "^9.0.7",
8
+ "@types/bcryptjs": "^2.4.6"
9
+ }
10
+ }
@@ -0,0 +1,16 @@
1
+ // Add these to your env schema validation
2
+
3
+ import { z } from 'zod';
4
+
5
+ export const jwtEnvSchema = z.object({
6
+ JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
7
+ JWT_EXPIRES_IN: z.string().default('15m'),
8
+ JWT_REFRESH_SECRET: z.string().min(32, 'JWT_REFRESH_SECRET must be at least 32 characters'),
9
+ JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
10
+ });
11
+
12
+ // Merge this with your existing env schema:
13
+ // const envSchema = z.object({
14
+ // ...existingFields,
15
+ // ...jwtEnvSchema.shape,
16
+ // });
@@ -0,0 +1,75 @@
1
+ import jwt from 'jsonwebtoken';
2
+ import { env } from '../config/env.js';
3
+ import { UnauthorizedError } from '../lib/errors.js';
4
+
5
+ export interface JwtPayload {
6
+ userId: string;
7
+ email: string;
8
+ type: 'access' | 'refresh';
9
+ }
10
+
11
+ export interface TokenPair {
12
+ accessToken: string;
13
+ refreshToken: string;
14
+ }
15
+
16
+ /**
17
+ * Generate access and refresh tokens for a user
18
+ */
19
+ export function generateTokens(userId: string, email: string): TokenPair {
20
+ const accessToken = jwt.sign(
21
+ { userId, email, type: 'access' } as JwtPayload,
22
+ env.JWT_SECRET,
23
+ { expiresIn: env.JWT_EXPIRES_IN }
24
+ );
25
+
26
+ const refreshToken = jwt.sign(
27
+ { userId, email, type: 'refresh' } as JwtPayload,
28
+ env.JWT_REFRESH_SECRET,
29
+ { expiresIn: env.JWT_REFRESH_EXPIRES_IN }
30
+ );
31
+
32
+ return { accessToken, refreshToken };
33
+ }
34
+
35
+ /**
36
+ * Verify and decode an access token
37
+ */
38
+ export function verifyAccessToken(token: string): JwtPayload {
39
+ try {
40
+ const decoded = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
41
+ if (decoded.type !== 'access') {
42
+ throw new UnauthorizedError('Invalid token type');
43
+ }
44
+ return decoded;
45
+ } catch (error) {
46
+ if (error instanceof jwt.TokenExpiredError) {
47
+ throw new UnauthorizedError('Token expired');
48
+ }
49
+ if (error instanceof jwt.JsonWebTokenError) {
50
+ throw new UnauthorizedError('Invalid token');
51
+ }
52
+ throw error;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Verify and decode a refresh token
58
+ */
59
+ export function verifyRefreshToken(token: string): JwtPayload {
60
+ try {
61
+ const decoded = jwt.verify(token, env.JWT_REFRESH_SECRET) as JwtPayload;
62
+ if (decoded.type !== 'refresh') {
63
+ throw new UnauthorizedError('Invalid token type');
64
+ }
65
+ return decoded;
66
+ } catch (error) {
67
+ if (error instanceof jwt.TokenExpiredError) {
68
+ throw new UnauthorizedError('Refresh token expired');
69
+ }
70
+ if (error instanceof jwt.JsonWebTokenError) {
71
+ throw new UnauthorizedError('Invalid refresh token');
72
+ }
73
+ throw error;
74
+ }
75
+ }
@@ -0,0 +1,50 @@
1
+ import type { Context, Next } from 'hono';
2
+ import { verifyAccessToken } from '../lib/jwt.js';
3
+ import { UnauthorizedError } from '../lib/errors.js';
4
+
5
+ /**
6
+ * Middleware to authenticate requests using JWT
7
+ * Extracts and verifies the JWT token from the Authorization header
8
+ * and attaches the decoded payload to c.get('jwtPayload')
9
+ */
10
+ export async function authMiddleware(c: Context, next: Next): Promise<void> {
11
+ const authHeader = c.req.header('Authorization');
12
+
13
+ if (!authHeader) {
14
+ throw new UnauthorizedError('No authorization header');
15
+ }
16
+
17
+ const [type, token] = authHeader.split(' ');
18
+
19
+ if (type !== 'Bearer' || !token) {
20
+ throw new UnauthorizedError('Invalid authorization header format');
21
+ }
22
+
23
+ const payload = verifyAccessToken(token);
24
+ c.set('jwtPayload', payload);
25
+
26
+ await next();
27
+ }
28
+
29
+ /**
30
+ * Optional authentication middleware
31
+ * Similar to authMiddleware but doesn't throw if no token is present
32
+ * Use this for routes that work for both authenticated and unauthenticated users
33
+ */
34
+ export async function optionalAuthMiddleware(c: Context, next: Next): Promise<void> {
35
+ const authHeader = c.req.header('Authorization');
36
+
37
+ if (authHeader) {
38
+ try {
39
+ const [type, token] = authHeader.split(' ');
40
+ if (type === 'Bearer' && token) {
41
+ const payload = verifyAccessToken(token);
42
+ c.set('jwtPayload', payload);
43
+ }
44
+ } catch {
45
+ // Silently ignore invalid tokens for optional auth
46
+ }
47
+ }
48
+
49
+ await next();
50
+ }
@@ -0,0 +1,157 @@
1
+ import { Hono } from 'hono';
2
+ import { zValidator } from '@hono/zod-validator';
3
+ import { z } from 'zod';
4
+ import bcrypt from 'bcryptjs';
5
+ import { generateTokens, verifyRefreshToken } from '../lib/jwt.js';
6
+ import { success, created } from '../lib/response.js';
7
+ import { UnauthorizedError, ConflictError } from '../lib/errors.js';
8
+
9
+ // NOTE: This is a basic example. In a real application, you would:
10
+ // 1. Store users in a database
11
+ // 2. Store refresh tokens in a database or Redis for invalidation
12
+ // 3. Add email verification
13
+ // 4. Add password reset functionality
14
+ // 5. Add rate limiting on auth endpoints
15
+ // 6. Add account lockout after failed attempts
16
+
17
+ const auth = new Hono();
18
+
19
+ // Validation schemas
20
+ const registerSchema = z.object({
21
+ email: z.string().email(),
22
+ password: z.string().min(8),
23
+ name: z.string().min(1),
24
+ });
25
+
26
+ const loginSchema = z.object({
27
+ email: z.string().email(),
28
+ password: z.string(),
29
+ });
30
+
31
+ const refreshSchema = z.object({
32
+ refreshToken: z.string(),
33
+ });
34
+
35
+ // In-memory user store (replace with database in production)
36
+ interface User {
37
+ id: string;
38
+ email: string;
39
+ password: string;
40
+ name: string;
41
+ createdAt: Date;
42
+ }
43
+
44
+ const users: Map<string, User> = new Map();
45
+
46
+ /**
47
+ * POST /auth/register
48
+ * Register a new user
49
+ */
50
+ auth.post('/register', zValidator('json', registerSchema), async (c) => {
51
+ const { email, password, name } = c.req.valid('json');
52
+
53
+ // Check if user already exists
54
+ const existingUser = Array.from(users.values()).find((u) => u.email === email);
55
+ if (existingUser) {
56
+ throw new ConflictError('User with this email already exists');
57
+ }
58
+
59
+ // Hash password
60
+ const hashedPassword = await bcrypt.hash(password, 10);
61
+
62
+ // Create user
63
+ const userId = crypto.randomUUID();
64
+ const user: User = {
65
+ id: userId,
66
+ email,
67
+ password: hashedPassword,
68
+ name,
69
+ createdAt: new Date(),
70
+ };
71
+
72
+ users.set(userId, user);
73
+
74
+ // Generate tokens
75
+ const tokens = generateTokens(userId, email);
76
+
77
+ return created(c, {
78
+ user: {
79
+ id: user.id,
80
+ email: user.email,
81
+ name: user.name,
82
+ createdAt: user.createdAt,
83
+ },
84
+ ...tokens,
85
+ });
86
+ });
87
+
88
+ /**
89
+ * POST /auth/login
90
+ * Login with email and password
91
+ */
92
+ auth.post('/login', zValidator('json', loginSchema), async (c) => {
93
+ const { email, password } = c.req.valid('json');
94
+
95
+ // Find user
96
+ const user = Array.from(users.values()).find((u) => u.email === email);
97
+ if (!user) {
98
+ throw new UnauthorizedError('Invalid email or password');
99
+ }
100
+
101
+ // Verify password
102
+ const isValidPassword = await bcrypt.compare(password, user.password);
103
+ if (!isValidPassword) {
104
+ throw new UnauthorizedError('Invalid email or password');
105
+ }
106
+
107
+ // Generate tokens
108
+ const tokens = generateTokens(user.id, user.email);
109
+
110
+ return success(c, {
111
+ user: {
112
+ id: user.id,
113
+ email: user.email,
114
+ name: user.name,
115
+ createdAt: user.createdAt,
116
+ },
117
+ ...tokens,
118
+ });
119
+ });
120
+
121
+ /**
122
+ * POST /auth/refresh
123
+ * Refresh access token using refresh token
124
+ */
125
+ auth.post('/refresh', zValidator('json', refreshSchema), async (c) => {
126
+ const { refreshToken } = c.req.valid('json');
127
+
128
+ // Verify refresh token
129
+ const payload = verifyRefreshToken(refreshToken);
130
+
131
+ // Get user
132
+ const user = users.get(payload.userId);
133
+ if (!user) {
134
+ throw new UnauthorizedError('User not found');
135
+ }
136
+
137
+ // Generate new tokens
138
+ const tokens = generateTokens(user.id, user.email);
139
+
140
+ return success(c, tokens);
141
+ });
142
+
143
+ /**
144
+ * POST /auth/logout
145
+ * Logout (client should discard tokens)
146
+ * In a real app, you would invalidate the refresh token in the database
147
+ */
148
+ auth.post('/logout', async (c) => {
149
+ // In a real application, you would:
150
+ // 1. Get refresh token from request
151
+ // 2. Delete it from database/Redis
152
+ // 3. Optionally blacklist the access token until it expires
153
+
154
+ return success(c, { message: 'Logged out successfully' });
155
+ });
156
+
157
+ export default auth;
@@ -0,0 +1,70 @@
1
+ version: '3.8'
2
+
3
+ services:
4
+ __IF_POSTGRES__ postgres:
5
+ __IF_POSTGRES__ image: postgres:16-alpine
6
+ __IF_POSTGRES__ container_name: __PROJECT_NAME__-postgres
7
+ __IF_POSTGRES__ environment:
8
+ __IF_POSTGRES__ POSTGRES_USER: postgres
9
+ __IF_POSTGRES__ POSTGRES_PASSWORD: postgres
10
+ __IF_POSTGRES__ POSTGRES_DB: __PROJECT_NAME__
11
+ __IF_POSTGRES__ ports:
12
+ __IF_POSTGRES__ - '5432:5432'
13
+ __IF_POSTGRES__ volumes:
14
+ __IF_POSTGRES__ - postgres_data:/var/lib/postgresql/data
15
+ __IF_POSTGRES__ healthcheck:
16
+ __IF_POSTGRES__ test: ['CMD-SHELL', 'pg_isready -U postgres']
17
+ __IF_POSTGRES__ interval: 10s
18
+ __IF_POSTGRES__ timeout: 5s
19
+ __IF_POSTGRES__ retries: 5
20
+ __IF_POSTGRES__
21
+ __IF_MONGODB__ mongodb:
22
+ __IF_MONGODB__ image: mongo:7-jammy
23
+ __IF_MONGODB__ container_name: __PROJECT_NAME__-mongodb
24
+ __IF_MONGODB__ environment:
25
+ __IF_MONGODB__ MONGO_INITDB_ROOT_USERNAME: root
26
+ __IF_MONGODB__ MONGO_INITDB_ROOT_PASSWORD: password
27
+ __IF_MONGODB__ MONGO_INITDB_DATABASE: __PROJECT_NAME__
28
+ __IF_MONGODB__ ports:
29
+ __IF_MONGODB__ - '27017:27017'
30
+ __IF_MONGODB__ volumes:
31
+ __IF_MONGODB__ - mongodb_data:/data/db
32
+ __IF_MONGODB__ healthcheck:
33
+ __IF_MONGODB__ test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/__PROJECT_NAME__ --quiet
34
+ __IF_MONGODB__ interval: 10s
35
+ __IF_MONGODB__ timeout: 5s
36
+ __IF_MONGODB__ retries: 5
37
+ __IF_MONGODB__
38
+ __IF_REDIS__ redis:
39
+ __IF_REDIS__ image: redis:7-alpine
40
+ __IF_REDIS__ container_name: __PROJECT_NAME__-redis
41
+ __IF_REDIS__ ports:
42
+ __IF_REDIS__ - '6379:6379'
43
+ __IF_REDIS__ volumes:
44
+ __IF_REDIS__ - redis_data:/data
45
+ __IF_REDIS__ healthcheck:
46
+ __IF_REDIS__ test: ['CMD', 'redis-cli', 'ping']
47
+ __IF_REDIS__ interval: 10s
48
+ __IF_REDIS__ timeout: 5s
49
+ __IF_REDIS__ retries: 5
50
+ __IF_REDIS__
51
+ __IF_SMTP__ mailhog:
52
+ __IF_SMTP__ image: mailhog/mailhog:latest
53
+ __IF_SMTP__ container_name: __PROJECT_NAME__-mailhog
54
+ __IF_SMTP__ ports:
55
+ __IF_SMTP__ - '1025:1025' # SMTP
56
+ __IF_SMTP__ - '8025:8025' # Web UI
57
+ __IF_SMTP__ healthcheck:
58
+ __IF_SMTP__ test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:8025']
59
+ __IF_SMTP__ interval: 10s
60
+ __IF_SMTP__ timeout: 5s
61
+ __IF_SMTP__ retries: 5
62
+
63
+ volumes:
64
+ __IF_POSTGRES__ postgres_data:
65
+ __IF_MONGODB__ mongodb_data:
66
+ __IF_REDIS__ redis_data:
67
+
68
+ networks:
69
+ default:
70
+ name: __PROJECT_NAME__-network