@yoms/create-monorepo 1.1.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 +244 -65
- package/package.json +19 -16
- package/templates/backend-hono/base/AGENT.md +326 -0
- package/templates/backend-hono/features/jwt-auth/env-additions.txt +5 -0
- package/templates/backend-hono/features/jwt-auth/package-additions.json +10 -0
- package/templates/backend-hono/features/jwt-auth/src/config/env-additions.ts +16 -0
- package/templates/backend-hono/features/jwt-auth/src/lib/jwt.ts +75 -0
- package/templates/backend-hono/features/jwt-auth/src/middleware/auth.middleware.ts +50 -0
- package/templates/backend-hono/features/jwt-auth/src/routes/auth.route.ts +157 -0
- package/templates/docker-compose.yml +70 -0
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
|
|
463
|
-
var
|
|
464
|
-
const 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 =
|
|
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
|
|
517
|
-
var
|
|
518
|
-
var
|
|
519
|
-
const dirname =
|
|
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 =
|
|
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
|
|
543
|
-
var
|
|
544
|
-
var
|
|
545
|
-
const dirname =
|
|
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 =
|
|
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
|
-
|
|
618
|
+
function showPreview(config) {
|
|
561
619
|
console.log("");
|
|
562
|
-
|
|
620
|
+
console.log(chalk.bold("\u{1F4E6} Project Preview:"));
|
|
563
621
|
console.log("");
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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("
|
|
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(
|
|
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(
|
|
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(`
|
|
649
|
-
|
|
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
|
-
|
|
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
|
|
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("
|
|
842
|
+
cli.version("1.2.0");
|
|
664
843
|
cli.parse();
|
package/package.json
CHANGED
|
@@ -1,24 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yoms/create-monorepo",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "CLI tool to scaffold monorepo projects from templates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"create-monorepo": "./dist/index.js"
|
|
8
8
|
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"dev": "tsx src/index.ts",
|
|
11
|
-
"build": "tsup src/index.ts --format esm --clean",
|
|
12
|
-
"typecheck": "tsc --noEmit",
|
|
13
|
-
"lint": "eslint src --ext .ts",
|
|
14
|
-
"format": "prettier --write \"src/**/*.ts\"",
|
|
15
|
-
"test:generate": "tsx scripts/test-generate.ts",
|
|
16
|
-
"test:validate": "tsx scripts/validate-templates.ts",
|
|
17
|
-
"changeset": "changeset",
|
|
18
|
-
"version": "changeset version",
|
|
19
|
-
"release": "pnpm build && changeset publish",
|
|
20
|
-
"prepublishOnly": "pnpm build && pnpm typecheck"
|
|
21
|
-
},
|
|
22
9
|
"keywords": [
|
|
23
10
|
"monorepo",
|
|
24
11
|
"template",
|
|
@@ -66,9 +53,25 @@
|
|
|
66
53
|
"prettier": "^3.4.2",
|
|
67
54
|
"tsup": "^8.3.5",
|
|
68
55
|
"tsx": "^4.19.2",
|
|
69
|
-
"typescript": "^5.7.2"
|
|
56
|
+
"typescript": "^5.7.2",
|
|
57
|
+
"vitest": "^2.1.8"
|
|
70
58
|
},
|
|
71
59
|
"engines": {
|
|
72
60
|
"node": ">=18.0.0"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"dev": "tsx src/index.ts",
|
|
64
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
65
|
+
"typecheck": "tsc --noEmit",
|
|
66
|
+
"lint": "eslint src --ext .ts",
|
|
67
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:watch": "vitest",
|
|
70
|
+
"test:e2e": "vitest run tests/e2e",
|
|
71
|
+
"test:generate": "tsx scripts/test-generate.ts",
|
|
72
|
+
"test:validate": "tsx scripts/validate-templates.ts",
|
|
73
|
+
"changeset": "changeset",
|
|
74
|
+
"version": "changeset version && pnpm install --lockfile-only",
|
|
75
|
+
"release": "pnpm build && changeset publish"
|
|
73
76
|
}
|
|
74
|
-
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# AGENT.md - Backend API
|
|
2
|
+
|
|
3
|
+
This file provides guidance to AI agents (Claude, Cursor, etc.) when working with this backend API.
|
|
4
|
+
|
|
5
|
+
## Project Overview
|
|
6
|
+
|
|
7
|
+
This is a Hono-based backend API in a monorepo workspace. It's designed for high performance, full type safety, and production-ready patterns.
|
|
8
|
+
|
|
9
|
+
## Tech Stack
|
|
10
|
+
|
|
11
|
+
- **Framework**: Hono (lightweight, fast, type-safe)
|
|
12
|
+
- **Language**: TypeScript with strict mode
|
|
13
|
+
- **Runtime**: Node.js (compatible with Bun, Deno, Cloudflare Workers)
|
|
14
|
+
- **Validation**: Zod schemas
|
|
15
|
+
- **Logger**: Winston with structured logging
|
|
16
|
+
- **Build Tool**: tsup (fast ESM bundler)
|
|
17
|
+
- **Testing**: Vitest with coverage
|
|
18
|
+
|
|
19
|
+
## Development Commands
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Development
|
|
23
|
+
pnpm dev # Start with hot reload
|
|
24
|
+
pnpm build # Build for production
|
|
25
|
+
pnpm start # Run production build
|
|
26
|
+
pnpm test # Run tests
|
|
27
|
+
pnpm test:watch # Watch mode
|
|
28
|
+
pnpm test:coverage # Coverage report
|
|
29
|
+
pnpm typecheck # Type checking
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Project Structure
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
src/
|
|
36
|
+
├── config/ # Configuration (env, logger, db)
|
|
37
|
+
├── lib/ # Utilities and helpers
|
|
38
|
+
│ ├── errors.ts # Custom error classes
|
|
39
|
+
│ └── response.ts # Response helpers
|
|
40
|
+
├── middleware/ # Hono middleware
|
|
41
|
+
│ ├── cors.middleware.ts
|
|
42
|
+
│ ├── error.middleware.ts
|
|
43
|
+
│ ├── logger.middleware.ts
|
|
44
|
+
│ └── rate-limit.middleware.ts
|
|
45
|
+
├── routes/ # API route handlers
|
|
46
|
+
│ └── health.route.ts
|
|
47
|
+
├── services/ # Business logic (if database enabled)
|
|
48
|
+
├── types/ # TypeScript types
|
|
49
|
+
└── index.ts # App entry point
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Architecture Patterns
|
|
53
|
+
|
|
54
|
+
### Error Handling
|
|
55
|
+
|
|
56
|
+
Use custom error classes for consistent error responses:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { NotFoundError, BadRequestError } from '../lib/errors.js';
|
|
60
|
+
|
|
61
|
+
// Throw custom errors - they're automatically handled
|
|
62
|
+
throw new NotFoundError('User not found');
|
|
63
|
+
throw new BadRequestError('Invalid email format');
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Available errors:
|
|
67
|
+
- `BadRequestError` (400)
|
|
68
|
+
- `UnauthorizedError` (401)
|
|
69
|
+
- `ForbiddenError` (403)
|
|
70
|
+
- `NotFoundError` (404)
|
|
71
|
+
- `ConflictError` (409)
|
|
72
|
+
- `ValidationError` (422)
|
|
73
|
+
- `TooManyRequestsError` (429)
|
|
74
|
+
- `InternalServerError` (500)
|
|
75
|
+
|
|
76
|
+
### Response Helpers
|
|
77
|
+
|
|
78
|
+
Use standardized response helpers:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { success, created, noContent, paginated } from '../lib/response.js';
|
|
82
|
+
|
|
83
|
+
// Success response (200)
|
|
84
|
+
return success(c, { id: '123', name: 'John' });
|
|
85
|
+
|
|
86
|
+
// Created response (201)
|
|
87
|
+
return created(c, newUser);
|
|
88
|
+
|
|
89
|
+
// No content (204)
|
|
90
|
+
return noContent(c);
|
|
91
|
+
|
|
92
|
+
// Paginated response
|
|
93
|
+
return paginated(c, users, page, limit, total);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Route Structure
|
|
97
|
+
|
|
98
|
+
Follow this pattern for new routes:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import { Hono } from 'hono';
|
|
102
|
+
import { zValidator } from '@hono/zod-validator';
|
|
103
|
+
import { success } from '../lib/response.js';
|
|
104
|
+
import { CreateItemSchema } from '@__PROJECT_NAME__/shared';
|
|
105
|
+
|
|
106
|
+
const items = new Hono();
|
|
107
|
+
|
|
108
|
+
// List items with pagination
|
|
109
|
+
items.get('/', zValidator('query', paginationSchema), async (c) => {
|
|
110
|
+
const { page, limit } = c.req.valid('query');
|
|
111
|
+
const result = await ItemService.getAll(page, limit);
|
|
112
|
+
return paginated(c, result.items, page, limit, result.total);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Get by ID
|
|
116
|
+
items.get('/:id', async (c) => {
|
|
117
|
+
const id = c.req.param('id');
|
|
118
|
+
const item = await ItemService.getById(id);
|
|
119
|
+
return success(c, item);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Create
|
|
123
|
+
items.post('/', zValidator('json', CreateItemSchema), async (c) => {
|
|
124
|
+
const data = c.req.valid('json');
|
|
125
|
+
const item = await ItemService.create(data);
|
|
126
|
+
return created(c, item);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
export { items };
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Service Layer Pattern
|
|
133
|
+
|
|
134
|
+
Create services for business logic:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { prisma } from '../config/database.js';
|
|
138
|
+
import { NotFoundError, ConflictError } from '../lib/errors.js';
|
|
139
|
+
|
|
140
|
+
export class ItemService {
|
|
141
|
+
static async getAll(page = 1, limit = 10) {
|
|
142
|
+
const skip = (page - 1) * limit;
|
|
143
|
+
const [items, total] = await Promise.all([
|
|
144
|
+
prisma.item.findMany({ skip, take: limit }),
|
|
145
|
+
prisma.item.count(),
|
|
146
|
+
]);
|
|
147
|
+
return { items, total, page, limit };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
static async getById(id: string) {
|
|
151
|
+
const item = await prisma.item.findUnique({ where: { id } });
|
|
152
|
+
if (!item) throw new NotFoundError(`Item ${id} not found`);
|
|
153
|
+
return item;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
static async create(data: CreateItem) {
|
|
157
|
+
// Check for duplicates
|
|
158
|
+
const existing = await prisma.item.findUnique({ where: { name: data.name } });
|
|
159
|
+
if (existing) throw new ConflictError('Item already exists');
|
|
160
|
+
|
|
161
|
+
return prisma.item.create({ data });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Environment Variables
|
|
167
|
+
|
|
168
|
+
All env vars are validated with Zod in `src/config/env.ts`. Add new variables:
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
const envSchema = z.object({
|
|
172
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
173
|
+
PORT: z.coerce.number().default(3001),
|
|
174
|
+
// Add your vars here
|
|
175
|
+
NEW_VAR: z.string().min(1),
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Rate Limiting
|
|
180
|
+
|
|
181
|
+
Apply rate limiting to routes:
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
184
|
+
import { rateLimits } from '../middleware/rate-limit.middleware.js';
|
|
185
|
+
|
|
186
|
+
// Use preset limits
|
|
187
|
+
app.use('/api/*', rateLimits.standard); // 100 req/min
|
|
188
|
+
|
|
189
|
+
// Custom limit
|
|
190
|
+
app.use('/auth/login', rateLimit({
|
|
191
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
192
|
+
max: 5,
|
|
193
|
+
message: 'Too many login attempts',
|
|
194
|
+
}));
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Testing
|
|
198
|
+
|
|
199
|
+
Write tests for all routes and services:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import { describe, it, expect } from 'vitest';
|
|
203
|
+
import { Hono } from 'hono';
|
|
204
|
+
import { items } from '../items.route.js';
|
|
205
|
+
|
|
206
|
+
describe('Items Route', () => {
|
|
207
|
+
const app = new Hono();
|
|
208
|
+
app.route('/items', items);
|
|
209
|
+
|
|
210
|
+
it('should list items', async () => {
|
|
211
|
+
const res = await app.request('/items?page=1&limit=10');
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
|
|
214
|
+
const json = await res.json();
|
|
215
|
+
expect(json.success).toBe(true);
|
|
216
|
+
expect(json.data).toBeInstanceOf(Array);
|
|
217
|
+
expect(json.pagination).toBeDefined();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## Database (if enabled)
|
|
223
|
+
|
|
224
|
+
### Prisma Commands
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
pnpm prisma:generate # Generate Prisma client
|
|
228
|
+
pnpm prisma:migrate # Create and run migrations
|
|
229
|
+
pnpm prisma:studio # Open Prisma Studio UI
|
|
230
|
+
pnpm prisma:push # Push schema to database
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Creating Migrations
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
# After editing schema.prisma
|
|
237
|
+
pnpm prisma:migrate
|
|
238
|
+
# Enter migration name when prompted
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Type Sharing
|
|
242
|
+
|
|
243
|
+
Import shared types from the shared package:
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
import { User, CreateUser, UpdateUser } from '@__PROJECT_NAME__/shared';
|
|
247
|
+
import { UserSchema } from '@__PROJECT_NAME__/shared';
|
|
248
|
+
|
|
249
|
+
// Use schemas for validation
|
|
250
|
+
const result = UserSchema.parse(data);
|
|
251
|
+
|
|
252
|
+
// Use types for type safety
|
|
253
|
+
const user: User = await getUser(id);
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## Best Practices
|
|
257
|
+
|
|
258
|
+
### DO:
|
|
259
|
+
- ✅ Use custom error classes, not raw `throw new Error()`
|
|
260
|
+
- ✅ Use response helpers for all routes
|
|
261
|
+
- ✅ Validate all input with Zod schemas
|
|
262
|
+
- ✅ Put business logic in services, not routes
|
|
263
|
+
- ✅ Write tests for new features
|
|
264
|
+
- ✅ Use type-safe imports from shared package
|
|
265
|
+
- ✅ Log important operations with structured logging
|
|
266
|
+
- ✅ Handle async operations properly with try/catch
|
|
267
|
+
|
|
268
|
+
### DON'T:
|
|
269
|
+
- ❌ Don't return raw `c.json()` - use response helpers
|
|
270
|
+
- ❌ Don't put business logic in routes - use services
|
|
271
|
+
- ❌ Don't use `any` type - maintain type safety
|
|
272
|
+
- ❌ Don't skip validation - always validate input
|
|
273
|
+
- ❌ Don't commit `.env` files - use `.env.example`
|
|
274
|
+
- ❌ Don't use blocking operations in async handlers
|
|
275
|
+
- ❌ Don't modify shared types here - edit in `packages/shared`
|
|
276
|
+
|
|
277
|
+
## Common Tasks
|
|
278
|
+
|
|
279
|
+
### Adding a New Route
|
|
280
|
+
|
|
281
|
+
1. Create route file in `src/routes/`
|
|
282
|
+
2. Create service in `src/services/` (if needed)
|
|
283
|
+
3. Add validation schemas in `packages/shared/src/schemas/`
|
|
284
|
+
4. Register route in `src/index.ts`
|
|
285
|
+
5. Write tests in `src/routes/__tests__/`
|
|
286
|
+
|
|
287
|
+
### Adding Middleware
|
|
288
|
+
|
|
289
|
+
1. Create in `src/middleware/`
|
|
290
|
+
2. Export middleware function
|
|
291
|
+
3. Apply in `src/index.ts` with `app.use()`
|
|
292
|
+
|
|
293
|
+
### Adding Environment Variables
|
|
294
|
+
|
|
295
|
+
1. Add to `.env.example`
|
|
296
|
+
2. Add to `src/config/env.ts` schema
|
|
297
|
+
3. Use via `env.YOUR_VAR`
|
|
298
|
+
|
|
299
|
+
## Debugging
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
// Use structured logging
|
|
303
|
+
import { logger } from './config/logger.js';
|
|
304
|
+
|
|
305
|
+
logger.info('User logged in', { userId: user.id });
|
|
306
|
+
logger.warn('Rate limit approaching', { ip, count });
|
|
307
|
+
logger.error('Database error', { error, query });
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Performance Tips
|
|
311
|
+
|
|
312
|
+
- Use database indexes for frequently queried fields
|
|
313
|
+
- Use pagination for list endpoints
|
|
314
|
+
- Cache expensive operations with Redis (if enabled)
|
|
315
|
+
- Use `prisma.$transaction()` for multiple related operations
|
|
316
|
+
- Profile with `pnpm test:coverage` to find slow tests
|
|
317
|
+
|
|
318
|
+
## Security Checklist
|
|
319
|
+
|
|
320
|
+
- [ ] All inputs are validated with Zod
|
|
321
|
+
- [ ] Rate limiting is applied to auth endpoints
|
|
322
|
+
- [ ] Sensitive data is not logged
|
|
323
|
+
- [ ] Database queries use parameterized queries (Prisma does this)
|
|
324
|
+
- [ ] CORS is configured correctly
|
|
325
|
+
- [ ] Environment variables are validated on startup
|
|
326
|
+
- [ ] Error messages don't leak sensitive info in production
|
|
@@ -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
|