create-saas-app-cli 1.2.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.
@@ -0,0 +1,1996 @@
1
+ // ─── Root package.json ────────────────────────────────────────────────────────
2
+ export function rootPackageJson(a) {
3
+ const pkgManager = a.packageManager;
4
+ const pmVersions = {
5
+ bun: "bun@1.3.8",
6
+ pnpm: "pnpm@9.0.0",
7
+ npm: "npm@10.0.0",
8
+ };
9
+ return JSON.stringify({
10
+ name: a.projectName,
11
+ private: true,
12
+ scripts: {
13
+ build: "turbo run build",
14
+ dev: "turbo run dev",
15
+ lint: "turbo run lint",
16
+ "check-types": "turbo run check-types",
17
+ test: "turbo run test",
18
+ "db:generate": "turbo run db:generate --filter=@saas/database",
19
+ "db:migrate": "turbo run db:migrate --filter=@saas/database",
20
+ "docker:up": "docker compose -f docker/docker-compose.yml up --build -d",
21
+ "docker:down": "docker compose -f docker/docker-compose.yml down",
22
+ },
23
+ devDependencies: {
24
+ "@types/node": "^22.0.0",
25
+ "@eslint/js": "^9.0.0",
26
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
27
+ "@typescript-eslint/parser": "^8.0.0",
28
+ eslint: "^9.0.0",
29
+ globals: "^15.0.0",
30
+ prettier: "^3.4.0",
31
+ turbo: "^2.8.10",
32
+ typescript: "5.7.3",
33
+ },
34
+ engines: { node: ">=18" },
35
+ packageManager: pmVersions[pkgManager],
36
+ workspaces: ["apps/*", "packages/*"],
37
+ }, null, 2);
38
+ }
39
+ // ─── turbo.json ───────────────────────────────────────────────────────────────
40
+ export function turboJson() {
41
+ return JSON.stringify({
42
+ $schema: "https://turborepo.dev/schema.json",
43
+ ui: "tui",
44
+ tasks: {
45
+ build: {
46
+ dependsOn: ["^build"],
47
+ inputs: ["$TURBO_DEFAULT$", ".env*"],
48
+ outputs: ["dist/**"],
49
+ },
50
+ "check-types": { dependsOn: ["^check-types"] },
51
+ lint: { dependsOn: ["^lint"] },
52
+ test: {
53
+ dependsOn: ["^build"],
54
+ inputs: ["src/**", "*.test.ts", "vitest.config.*"],
55
+ outputs: ["coverage/**"],
56
+ },
57
+ dev: { cache: false, persistent: true },
58
+ "db:generate": { cache: false },
59
+ "db:migrate": { cache: false },
60
+ },
61
+ }, null, 2);
62
+ }
63
+ // ─── .gitignore ───────────────────────────────────────────────────────────────
64
+ export function gitignoreTemplate() {
65
+ return `node_modules/
66
+ dist/
67
+ .env
68
+ .env.local
69
+ .turbo/
70
+ coverage/
71
+ *.log
72
+ *.db
73
+ bun.lockb
74
+ pnpm-lock.yaml
75
+ package-lock.json
76
+ .DS_Store
77
+ `;
78
+ }
79
+ // ─── .npmrc ───────────────────────────────────────────────────────────────────
80
+ export function npmrcTemplate(a) {
81
+ if (a.packageManager === "pnpm") {
82
+ return `shamefully-hoist=true\n`;
83
+ }
84
+ return ``;
85
+ }
86
+ // ─── .env.example ─────────────────────────────────────────────────────────────
87
+ export function envExampleTemplate(a) {
88
+ const isMongo = a.database === "mongodb-mongoose";
89
+ const dbLine = isMongo
90
+ ? `MONGODB_URI=mongodb://localhost:27017/${a.projectName}`
91
+ : `DATABASE_URL=postgres://saas:saaspassword@localhost:5432/saas`;
92
+ const redisLine = a.includeQueue || a.rateLimit === "redis"
93
+ ? `\n# ─── Redis ──────────────────────────────────────────────────────────────────\nREDIS_URL=redis://localhost:6379`
94
+ : "";
95
+ const rateLimitSection = a.rateLimit !== "none"
96
+ ? `\n# ─── Rate Limiting ───────────────────────────────────────────────────────────\nRATE_LIMIT_WINDOW_MS=60000\nRATE_LIMIT_MAX_REQUESTS=100`
97
+ : "";
98
+ const paymentsSection = a.includePayments
99
+ ? `\n# ─── Razorpay ────────────────────────────────────────────────────────────────\nRAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxx\nRAZORPAY_KEY_SECRET=change-me\nRAZORPAY_WEBHOOK_SECRET=change-me`
100
+ : "";
101
+ const emailSection = a.emailProvider === "resend"
102
+ ? `\n# ─── Email (Resend) ──────────────────────────────────────────────────────────\nRESEND_API_KEY=re_xxxxxxxxxxxx\nEMAIL_FROM=noreply@yoursaas.com`
103
+ : a.emailProvider === "nodemailer"
104
+ ? `\n# ─── Email (SMTP) ────────────────────────────────────────────────────────────\nSMTP_HOST=smtp.example.com\nSMTP_PORT=587\nSMTP_SECURE=false\nSMTP_USER=user@example.com\nSMTP_PASS=change-me\nEMAIL_FROM=noreply@yoursaas.com`
105
+ : "";
106
+ return `# ─── Application ─────────────────────────────────────────────────────────────
107
+ NODE_ENV=development
108
+ PORT=3000
109
+
110
+ # ─── Database ─────────────────────────────────────────────────────────────────
111
+ ${dbLine}
112
+ ${redisLine}
113
+
114
+ # ─── Auth ─────────────────────────────────────────────────────────────────────
115
+ JWT_SECRET=change-me-to-a-long-random-secret-32-chars-min
116
+ JWT_EXPIRES_IN=7d
117
+
118
+ # ─── Admin ────────────────────────────────────────────────────────────────────
119
+ ADMIN_SECRET=change-me-admin-secret
120
+ ${rateLimitSection}
121
+ ${paymentsSection}
122
+ ${emailSection}
123
+
124
+ # ─── Observability ────────────────────────────────────────────────────────────
125
+ LOG_LEVEL=info
126
+ `;
127
+ }
128
+ // ─── API app package.json ─────────────────────────────────────────────────────
129
+ export function apiPackageJson(a) {
130
+ const dbDeps = dbDependencies(a.database);
131
+ const rateDeps = rateLimitDependencies(a.rateLimit);
132
+ const deps = {
133
+ express: "^4.21.2",
134
+ helmet: "^8.0.0",
135
+ cors: "^2.8.5",
136
+ "@saas/config": "*",
137
+ "@saas/logger": "*",
138
+ "@saas/types": "*",
139
+ ...dbDeps,
140
+ ...rateDeps,
141
+ };
142
+ if (a.includeAuth)
143
+ deps["@saas/auth"] = "*";
144
+ if (a.includeQueue) {
145
+ deps["@saas/queue"] = "*";
146
+ deps["@saas/redis"] = "*";
147
+ deps["bullmq"] = "^5.0.0";
148
+ }
149
+ if (a.includePayments)
150
+ deps["@saas/payments"] = "*";
151
+ if (a.emailProvider !== "none")
152
+ deps["@saas/email"] = "*";
153
+ return JSON.stringify({
154
+ name: "@saas/api",
155
+ version: "0.1.0",
156
+ private: true,
157
+ type: "module",
158
+ scripts: {
159
+ dev: "tsx watch src/index.ts",
160
+ build: "tsc --project tsconfig.build.json",
161
+ start: "node dist/index.js",
162
+ "check-types": "tsc --noEmit",
163
+ lint: "eslint src/",
164
+ },
165
+ dependencies: deps,
166
+ devDependencies: {
167
+ "@saas/typescript-config": "*",
168
+ "@types/cors": "^2.8.18",
169
+ "@types/express": "^5.0.1",
170
+ "@types/node": "^22.0.0",
171
+ tsx: "^4.19.3",
172
+ typescript: "5.7.3",
173
+ vitest: "^3.0.7",
174
+ },
175
+ }, null, 2);
176
+ }
177
+ // ─── Worker app package.json ──────────────────────────────────────────────────
178
+ export function workerPackageJson(a) {
179
+ const dbDeps = dbDependencies(a.database);
180
+ const deps = {
181
+ bullmq: "^5.0.0",
182
+ "@saas/config": "*",
183
+ "@saas/logger": "*",
184
+ "@saas/redis": "*",
185
+ "@saas/types": "*",
186
+ ...dbDeps,
187
+ };
188
+ if (a.includeQueue)
189
+ deps["@saas/queue"] = "*";
190
+ return JSON.stringify({
191
+ name: "@saas/worker",
192
+ version: "0.1.0",
193
+ private: true,
194
+ type: "module",
195
+ scripts: {
196
+ dev: "tsx watch src/index.ts",
197
+ build: "tsc --project tsconfig.build.json",
198
+ start: "node dist/index.js",
199
+ "check-types": "tsc --noEmit",
200
+ lint: "eslint src/",
201
+ },
202
+ dependencies: deps,
203
+ devDependencies: {
204
+ "@saas/typescript-config": "*",
205
+ "@types/node": "^22.0.0",
206
+ tsx: "^4.19.3",
207
+ typescript: "5.7.3",
208
+ vitest: "^3.0.7",
209
+ },
210
+ }, null, 2);
211
+ }
212
+ // ─── Shared tsconfig.json for apps ───────────────────────────────────────────
213
+ export function appTsconfig() {
214
+ return JSON.stringify({
215
+ extends: "@saas/typescript-config/base.json",
216
+ compilerOptions: {
217
+ outDir: "./dist",
218
+ rootDir: "./src",
219
+ },
220
+ include: ["src"],
221
+ }, null, 2);
222
+ }
223
+ export function appTsconfigBuild() {
224
+ return JSON.stringify({
225
+ extends: "./tsconfig.json",
226
+ exclude: ["node_modules", "dist", "**/*.test.ts"],
227
+ }, null, 2);
228
+ }
229
+ // ─── API source files ─────────────────────────────────────────────────────────
230
+ export function apiIndexTs() {
231
+ return `import { createApp } from "./app.js";
232
+ import { config } from "@saas/config";
233
+ import { createLogger } from "@saas/logger";
234
+
235
+ const logger = createLogger("api");
236
+
237
+ createApp().then((app) => {
238
+ app.listen(config.port, () => {
239
+ logger.info({ port: config.port }, "API server started");
240
+ });
241
+ }).catch((err) => {
242
+ console.error("Failed to start server:", err);
243
+ process.exit(1);
244
+ });
245
+ `;
246
+ }
247
+ export function apiAppTs(a) {
248
+ const isMongo = a.database === "mongodb-mongoose";
249
+ const dbImport = isMongo
250
+ ? `import { connectDb } from "@saas/database";`
251
+ : `import { db } from "@saas/database";`;
252
+ const rateLimitImport = a.rateLimit !== "none"
253
+ ? `import { rateLimitMiddleware } from "./middleware/rateLimit.js";`
254
+ : "";
255
+ const authImport = a.includeAuth
256
+ ? `import { authMiddleware } from "@saas/auth";`
257
+ : "";
258
+ const queueImport = a.includeQueue
259
+ ? `import { createQueues } from "@saas/queue";
260
+ import { getRedisClient } from "@saas/redis";`
261
+ : "";
262
+ const queueSetup = a.includeQueue
263
+ ? `\n const redis = getRedisClient();
264
+ const _queues = createQueues(redis);`
265
+ : "";
266
+ const mongoSetup = isMongo ? `\n await connectDb();` : "";
267
+ const rateLimitUse = a.rateLimit !== "none" ? `\n app.use(rateLimitMiddleware());` : "";
268
+ const authUse = a.includeAuth
269
+ ? `\n // Protected routes — attach auth middleware where needed\n // app.use("/api/v1", authMiddleware(), ...routes);`
270
+ : "";
271
+ return `import express, { type Express } from "express";
272
+ import helmet from "helmet";
273
+ import cors from "cors";
274
+ import { createLogger } from "@saas/logger";
275
+ ${dbImport}
276
+ ${queueImport}
277
+ ${rateLimitImport}
278
+ ${authImport}
279
+ import { healthRouter } from "./routes/health.js";
280
+
281
+ export async function createApp(): Promise<Express> {
282
+ const app = express();
283
+ const logger = createLogger("api");
284
+ ${mongoSetup}
285
+ ${queueSetup}
286
+
287
+ app.use(helmet());
288
+ app.use(cors());
289
+ app.use(express.json({ limit: "1mb" }));
290
+ ${rateLimitUse}
291
+ ${authUse}
292
+
293
+ app.use("/health", healthRouter());
294
+
295
+ // TODO: add your routes here
296
+ // app.use("/api/v1", tenantMiddleware(), yourRouter());
297
+
298
+ app.use(
299
+ (
300
+ err: Error,
301
+ _req: express.Request,
302
+ res: express.Response,
303
+ _next: express.NextFunction
304
+ ) => {
305
+ logger.error({ err }, "Unhandled error");
306
+ res.status(500).json({ success: false, error: { message: err.message } });
307
+ }
308
+ );
309
+
310
+ return app;
311
+ }
312
+ `;
313
+ }
314
+ export function apiHealthRouteTs() {
315
+ return `import { Router } from "express";
316
+
317
+ export function healthRouter(): Router {
318
+ const router = Router();
319
+
320
+ router.get("/", (_req, res) => {
321
+ res.json({ status: "ok", timestamp: new Date().toISOString() });
322
+ });
323
+
324
+ return router;
325
+ }
326
+ `;
327
+ }
328
+ export function apiRateLimitTs(strategy) {
329
+ if (strategy === "redis") {
330
+ return `import rateLimit from "express-rate-limit";
331
+ import { RedisStore } from "rate-limit-redis";
332
+ import { getRedisClient } from "@saas/redis";
333
+ import type { RequestHandler } from "express";
334
+
335
+ export function rateLimitMiddleware(): RequestHandler {
336
+ const client = getRedisClient();
337
+ return rateLimit({
338
+ windowMs: parseInt(process.env["RATE_LIMIT_WINDOW_MS"] ?? "60000"),
339
+ max: parseInt(process.env["RATE_LIMIT_MAX_REQUESTS"] ?? "100"),
340
+ standardHeaders: true,
341
+ legacyHeaders: false,
342
+ store: new RedisStore({ sendCommand: (...args) => client.call(...args) }),
343
+ });
344
+ }
345
+ `;
346
+ }
347
+ return `import rateLimit from "express-rate-limit";
348
+ import type { RequestHandler } from "express";
349
+
350
+ export function rateLimitMiddleware(): RequestHandler {
351
+ return rateLimit({
352
+ windowMs: parseInt(process.env["RATE_LIMIT_WINDOW_MS"] ?? "60000"),
353
+ max: parseInt(process.env["RATE_LIMIT_MAX_REQUESTS"] ?? "100"),
354
+ standardHeaders: true,
355
+ legacyHeaders: false,
356
+ });
357
+ }
358
+ `;
359
+ }
360
+ // ─── Worker source files ──────────────────────────────────────────────────────
361
+ export function workerIndexTs() {
362
+ return `import { Worker } from "bullmq";
363
+ import { getRedisClient } from "@saas/redis";
364
+ import { createLogger } from "@saas/logger";
365
+
366
+ const logger = createLogger("worker");
367
+ const redis = getRedisClient();
368
+
369
+ const worker = new Worker(
370
+ "default",
371
+ async (job) => {
372
+ logger.info({ jobId: job.id, name: job.name }, "Processing job");
373
+ // TODO: handle your jobs here
374
+ },
375
+ { connection: redis }
376
+ );
377
+
378
+ worker.on("completed", (job) => {
379
+ logger.info({ jobId: job.id }, "Job completed");
380
+ });
381
+
382
+ worker.on("failed", (job, err) => {
383
+ logger.error({ jobId: job?.id, err }, "Job failed");
384
+ });
385
+
386
+ logger.info("Worker started, listening for jobs...");
387
+ `;
388
+ }
389
+ // ─── Database package ─────────────────────────────────────────────────────────
390
+ export function dbPackageJson(db) {
391
+ const deps = {};
392
+ const devDeps = {
393
+ "@saas/typescript-config": "*",
394
+ typescript: "5.7.3",
395
+ };
396
+ const scripts = {
397
+ "check-types": "tsc --noEmit",
398
+ };
399
+ if (db === "mongodb-mongoose") {
400
+ deps["mongoose"] = "^8.0.0";
401
+ }
402
+ else if (db === "postgres-drizzle" || db === "sqlite-drizzle") {
403
+ deps["drizzle-orm"] = "^0.40.0";
404
+ if (db === "postgres-drizzle")
405
+ deps["postgres"] = "^3.4.5";
406
+ if (db === "sqlite-drizzle")
407
+ deps["better-sqlite3"] = "^9.0.0";
408
+ devDeps["drizzle-kit"] = "^0.30.4";
409
+ scripts["db:generate"] = "drizzle-kit generate";
410
+ scripts["db:migrate"] = "drizzle-kit migrate";
411
+ scripts["db:push"] = "drizzle-kit push";
412
+ scripts["db:studio"] = "drizzle-kit studio";
413
+ }
414
+ else if (db === "postgres-prisma") {
415
+ deps["@prisma/client"] = "^5.0.0";
416
+ devDeps["prisma"] = "^5.0.0";
417
+ scripts["db:generate"] = "prisma generate";
418
+ scripts["db:migrate"] = "prisma migrate dev";
419
+ scripts["db:push"] = "prisma db push";
420
+ scripts["db:studio"] = "prisma studio";
421
+ }
422
+ return JSON.stringify({
423
+ name: "@saas/database",
424
+ version: "0.1.0",
425
+ private: true,
426
+ type: "module",
427
+ main: "./src/index.ts",
428
+ exports: { ".": "./src/index.ts" },
429
+ scripts,
430
+ dependencies: deps,
431
+ devDependencies: devDeps,
432
+ }, null, 2);
433
+ }
434
+ export function dbIndexTs(db) {
435
+ if (db === "mongodb-mongoose") {
436
+ return `import mongoose from "mongoose";
437
+
438
+ export async function connectDb(): Promise<void> {
439
+ const uri = process.env["MONGODB_URI"];
440
+ if (!uri) throw new Error("MONGODB_URI is not set");
441
+ await mongoose.connect(uri);
442
+ }
443
+
444
+ export { mongoose };
445
+ `;
446
+ }
447
+ if (db === "postgres-drizzle") {
448
+ return `import { drizzle } from "drizzle-orm/postgres-js";
449
+ import postgres from "postgres";
450
+
451
+ const connectionString = process.env["DATABASE_URL"];
452
+ if (!connectionString) throw new Error("DATABASE_URL is not set");
453
+
454
+ const client = postgres(connectionString);
455
+ export const db = drizzle(client);
456
+ `;
457
+ }
458
+ if (db === "postgres-prisma") {
459
+ return `import { PrismaClient } from "@prisma/client";
460
+
461
+ const globalForPrisma = globalThis as { prisma?: PrismaClient };
462
+ export const db = globalForPrisma.prisma ?? new PrismaClient();
463
+ if (process.env["NODE_ENV"] !== "production") globalForPrisma.prisma = db;
464
+ `;
465
+ }
466
+ // sqlite-drizzle
467
+ return `import { drizzle } from "drizzle-orm/better-sqlite3";
468
+ import Database from "better-sqlite3";
469
+
470
+ const sqlite = new Database("./local.db");
471
+ export const db = drizzle(sqlite);
472
+ `;
473
+ }
474
+ // ─── Config package ───────────────────────────────────────────────────────────
475
+ export function configPackageJson() {
476
+ return JSON.stringify({
477
+ name: "@saas/config",
478
+ version: "0.1.0",
479
+ private: true,
480
+ type: "module",
481
+ main: "./src/index.ts",
482
+ exports: { ".": "./src/index.ts" },
483
+ scripts: { "check-types": "tsc --noEmit" },
484
+ devDependencies: {
485
+ "@saas/typescript-config": "*",
486
+ typescript: "5.7.3",
487
+ },
488
+ }, null, 2);
489
+ }
490
+ export function configIndexTs() {
491
+ return `export const config = {
492
+ port: parseInt(process.env["PORT"] ?? "3000"),
493
+ nodeEnv: process.env["NODE_ENV"] ?? "development",
494
+ isDev: process.env["NODE_ENV"] !== "production",
495
+ jwtSecret: process.env["JWT_SECRET"] ?? "change-me",
496
+ jwtExpiresIn: process.env["JWT_EXPIRES_IN"] ?? "7d",
497
+ adminSecret: process.env["ADMIN_SECRET"] ?? "change-me",
498
+ logLevel: process.env["LOG_LEVEL"] ?? "info",
499
+ } as const;
500
+ `;
501
+ }
502
+ // ─── Logger package ───────────────────────────────────────────────────────────
503
+ export function loggerPackageJson() {
504
+ return JSON.stringify({
505
+ name: "@saas/logger",
506
+ version: "0.1.0",
507
+ private: true,
508
+ type: "module",
509
+ main: "./src/index.ts",
510
+ exports: { ".": "./src/index.ts" },
511
+ scripts: { "check-types": "tsc --noEmit" },
512
+ dependencies: { pino: "^9.0.0" },
513
+ devDependencies: {
514
+ "@saas/typescript-config": "*",
515
+ "@types/node": "^22.0.0",
516
+ "pino-pretty": "^13.0.0",
517
+ typescript: "5.7.3",
518
+ },
519
+ }, null, 2);
520
+ }
521
+ export function loggerIndexTs() {
522
+ return `import pino from "pino";
523
+
524
+ export function createLogger(name: string) {
525
+ return pino({
526
+ name,
527
+ level: process.env["LOG_LEVEL"] ?? "info",
528
+ transport:
529
+ process.env["NODE_ENV"] !== "production"
530
+ ? { target: "pino-pretty", options: { colorize: true } }
531
+ : undefined,
532
+ });
533
+ }
534
+
535
+ export type Logger = ReturnType<typeof createLogger>;
536
+ `;
537
+ }
538
+ // ─── Auth package ─────────────────────────────────────────────────────────────
539
+ export function authPackageJson() {
540
+ return JSON.stringify({
541
+ name: "@saas/auth",
542
+ version: "0.1.0",
543
+ private: true,
544
+ type: "module",
545
+ main: "./src/index.ts",
546
+ exports: { ".": "./src/index.ts" },
547
+ scripts: { "check-types": "tsc --noEmit" },
548
+ dependencies: {
549
+ jsonwebtoken: "^9.0.0",
550
+ bcryptjs: "^2.4.3",
551
+ "@saas/config": "*",
552
+ },
553
+ devDependencies: {
554
+ "@saas/typescript-config": "*",
555
+ "@types/jsonwebtoken": "^9.0.0",
556
+ "@types/bcryptjs": "^2.4.0",
557
+ typescript: "5.7.3",
558
+ },
559
+ }, null, 2);
560
+ }
561
+ export function authIndexTs() {
562
+ return `import jwt from "jsonwebtoken";
563
+ import bcrypt from "bcryptjs";
564
+ import { config } from "@saas/config";
565
+ import type { RequestHandler } from "express";
566
+
567
+ export function signToken(payload: Record<string, unknown>): string {
568
+ return jwt.sign(payload, config.jwtSecret, {
569
+ expiresIn: config.jwtExpiresIn as jwt.SignOptions["expiresIn"],
570
+ });
571
+ }
572
+
573
+ export function verifyToken(token: string): jwt.JwtPayload {
574
+ return jwt.verify(token, config.jwtSecret) as jwt.JwtPayload;
575
+ }
576
+
577
+ export async function hashPassword(password: string): Promise<string> {
578
+ return bcrypt.hash(password, 12);
579
+ }
580
+
581
+ export async function comparePassword(
582
+ password: string,
583
+ hash: string
584
+ ): Promise<boolean> {
585
+ return bcrypt.compare(password, hash);
586
+ }
587
+
588
+ export function authMiddleware(): RequestHandler {
589
+ return (req, res, next) => {
590
+ const header = req.headers["authorization"];
591
+ if (!header?.startsWith("Bearer ")) {
592
+ res.status(401).json({ success: false, error: { message: "Unauthorized" } });
593
+ return;
594
+ }
595
+ try {
596
+ const token = header.slice(7);
597
+ const payload = verifyToken(token);
598
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
599
+ (req as any).user = payload;
600
+ next();
601
+ } catch {
602
+ res.status(401).json({ success: false, error: { message: "Invalid token" } });
603
+ }
604
+ };
605
+ }
606
+ `;
607
+ }
608
+ // ─── Queue package ────────────────────────────────────────────────────────────
609
+ export function queuePackageJson() {
610
+ return JSON.stringify({
611
+ name: "@saas/queue",
612
+ version: "0.1.0",
613
+ private: true,
614
+ type: "module",
615
+ main: "./src/index.ts",
616
+ exports: { ".": "./src/index.ts" },
617
+ scripts: { "check-types": "tsc --noEmit" },
618
+ dependencies: {
619
+ bullmq: "^5.0.0",
620
+ "@saas/redis": "*",
621
+ },
622
+ devDependencies: {
623
+ "@saas/typescript-config": "*",
624
+ typescript: "5.7.3",
625
+ },
626
+ }, null, 2);
627
+ }
628
+ export function queueIndexTs() {
629
+ return `import { Queue } from "bullmq";
630
+ import type { Redis } from "ioredis";
631
+
632
+ export function createQueues(redis: Redis) {
633
+ const defaultQueue = new Queue("default", { connection: redis });
634
+ const emailQueue = new Queue("email", { connection: redis });
635
+
636
+ return { defaultQueue, emailQueue };
637
+ }
638
+
639
+ export type Queues = ReturnType<typeof createQueues>;
640
+ `;
641
+ }
642
+ // ─── Redis package ────────────────────────────────────────────────────────────
643
+ export function redisPackageJson() {
644
+ return JSON.stringify({
645
+ name: "@saas/redis",
646
+ version: "0.1.0",
647
+ private: true,
648
+ type: "module",
649
+ main: "./src/index.ts",
650
+ exports: { ".": "./src/index.ts" },
651
+ scripts: { "check-types": "tsc --noEmit" },
652
+ dependencies: { ioredis: "^5.3.0" },
653
+ devDependencies: {
654
+ "@saas/typescript-config": "*",
655
+ typescript: "5.7.3",
656
+ },
657
+ }, null, 2);
658
+ }
659
+ export function redisIndexTs() {
660
+ return `import Redis from "ioredis";
661
+
662
+ let client: Redis | null = null;
663
+
664
+ export function getRedisClient(): Redis {
665
+ if (!client) {
666
+ const url = process.env["REDIS_URL"] ?? "redis://localhost:6379";
667
+ client = new Redis(url, { maxRetriesPerRequest: null });
668
+ }
669
+ return client;
670
+ }
671
+ `;
672
+ }
673
+ // ─── Types package ────────────────────────────────────────────────────────────
674
+ export function typesPackageJson() {
675
+ return JSON.stringify({
676
+ name: "@saas/types",
677
+ version: "0.1.0",
678
+ private: true,
679
+ type: "module",
680
+ main: "./src/index.ts",
681
+ exports: { ".": "./src/index.ts" },
682
+ scripts: { "check-types": "tsc --noEmit" },
683
+ devDependencies: {
684
+ "@saas/typescript-config": "*",
685
+ typescript: "5.7.3",
686
+ },
687
+ }, null, 2);
688
+ }
689
+ export function typesIndexTs() {
690
+ return `export interface Tenant {
691
+ id: string;
692
+ name: string;
693
+ slug: string;
694
+ plan: "free" | "pro" | "enterprise";
695
+ createdAt: Date;
696
+ }
697
+
698
+ export interface ApiResponse<T = unknown> {
699
+ success: boolean;
700
+ data?: T;
701
+ error?: {
702
+ code?: string;
703
+ message: string;
704
+ };
705
+ }
706
+
707
+ export interface PaginatedResponse<T> extends ApiResponse<T[]> {
708
+ pagination: {
709
+ page: number;
710
+ pageSize: number;
711
+ total: number;
712
+ };
713
+ }
714
+ `;
715
+ }
716
+ // ─── TypeScript config package ────────────────────────────────────────────────
717
+ export function typescriptConfigPackageJson() {
718
+ return JSON.stringify({
719
+ name: "@saas/typescript-config",
720
+ version: "0.1.0",
721
+ private: true,
722
+ type: "module",
723
+ exports: { "./base.json": "./base.json" },
724
+ }, null, 2);
725
+ }
726
+ export function typescriptConfigBase() {
727
+ return JSON.stringify({
728
+ $schema: "https://json.schemastore.org/tsconfig",
729
+ display: "Base",
730
+ compilerOptions: {
731
+ target: "ES2022",
732
+ module: "NodeNext",
733
+ moduleResolution: "NodeNext",
734
+ strict: true,
735
+ esModuleInterop: true,
736
+ skipLibCheck: true,
737
+ declaration: true,
738
+ declarationMap: true,
739
+ sourceMap: true,
740
+ },
741
+ }, null, 2);
742
+ }
743
+ // ─── GitHub Actions CI ───────────────────────────────────────────────────────
744
+ export function githubActionsCiWorkflow(a) {
745
+ const isBun = a.packageManager === "bun";
746
+ const isPnpm = a.packageManager === "pnpm";
747
+ const setupRuntime = isBun
748
+ ? ` - name: Setup Bun
749
+ uses: oven-sh/setup-bun@v2
750
+ with:
751
+ bun-version: 1.3.8`
752
+ : ` - name: Setup Node.js
753
+ uses: actions/setup-node@v4
754
+ with:
755
+ node-version: 20
756
+ cache: ${isPnpm ? "pnpm" : "npm"}`;
757
+ const corepackStep = isPnpm
758
+ ? ` - name: Enable Corepack
759
+ run: corepack enable
760
+ `
761
+ : "";
762
+ const installCmd = isBun
763
+ ? "bun install --frozen-lockfile"
764
+ : isPnpm
765
+ ? "pnpm install --frozen-lockfile"
766
+ : "npm ci";
767
+ const runCmd = isBun ? "bun run" : `${a.packageManager} run`;
768
+ return `name: CI
769
+
770
+ on:
771
+ push:
772
+ branches: ["main"]
773
+ pull_request:
774
+
775
+ permissions:
776
+ contents: read
777
+
778
+ jobs:
779
+ checks:
780
+ runs-on: ubuntu-latest
781
+ steps:
782
+ - name: Checkout
783
+ uses: actions/checkout@v4
784
+ ${setupRuntime}
785
+ ${corepackStep} - name: Install dependencies
786
+ run: ${installCmd}
787
+ - name: Type check
788
+ run: ${runCmd} check-types
789
+ - name: Lint
790
+ run: ${runCmd} lint
791
+ - name: Test
792
+ run: ${runCmd} test
793
+ - name: Build
794
+ run: ${runCmd} build
795
+ `;
796
+ }
797
+ // ─── Docker compose ───────────────────────────────────────────────────────────
798
+ export function dockerComposeTemplate(a) {
799
+ const isMongo = a.database === "mongodb-mongoose";
800
+ const needsRedis = a.includeQueue || a.rateLimit === "redis" || a.includeWorker;
801
+ const mongoService = isMongo
802
+ ? `
803
+ mongo:
804
+ image: mongo:7
805
+ restart: unless-stopped
806
+ ports:
807
+ - "27017:27017"
808
+ volumes:
809
+ - mongo_data:/data/db
810
+ healthcheck:
811
+ test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
812
+ interval: 10s
813
+ timeout: 5s
814
+ retries: 5
815
+ `
816
+ : `
817
+ postgres:
818
+ image: postgres:16-alpine
819
+ restart: unless-stopped
820
+ environment:
821
+ POSTGRES_USER: saas
822
+ POSTGRES_PASSWORD: saaspassword
823
+ POSTGRES_DB: saas
824
+ ports:
825
+ - "5432:5432"
826
+ volumes:
827
+ - postgres_data:/var/lib/postgresql/data
828
+ healthcheck:
829
+ test: ["CMD-SHELL", "pg_isready -U saas -d saas"]
830
+ interval: 10s
831
+ timeout: 5s
832
+ retries: 5
833
+ `;
834
+ const redisService = needsRedis
835
+ ? `
836
+ redis:
837
+ image: redis:7-alpine
838
+ restart: unless-stopped
839
+ command: redis-server --appendonly yes
840
+ ports:
841
+ - "6379:6379"
842
+ volumes:
843
+ - redis_data:/data
844
+ healthcheck:
845
+ test: ["CMD", "redis-cli", "ping"]
846
+ interval: 10s
847
+ timeout: 5s
848
+ retries: 5
849
+ `
850
+ : "";
851
+ const obsServices = a.includeObservability
852
+ ? `
853
+ loki:
854
+ image: grafana/loki:latest
855
+ restart: unless-stopped
856
+ ports:
857
+ - "3100:3100"
858
+ volumes:
859
+ - loki_data:/loki
860
+
861
+ prometheus:
862
+ image: prom/prometheus:latest
863
+ restart: unless-stopped
864
+ ports:
865
+ - "9090:9090"
866
+ volumes:
867
+ - ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
868
+ - prometheus_data:/prometheus
869
+
870
+ grafana:
871
+ image: grafana/grafana:latest
872
+ restart: unless-stopped
873
+ ports:
874
+ - "3001:3000"
875
+ environment:
876
+ GF_SECURITY_ADMIN_USER: admin
877
+ GF_SECURITY_ADMIN_PASSWORD: admin
878
+ volumes:
879
+ - grafana_data:/var/lib/grafana
880
+ - ./observability/grafana/provisioning:/etc/grafana/provisioning
881
+ depends_on:
882
+ - prometheus
883
+ - loki
884
+ `
885
+ : "";
886
+ const dbDepends = isMongo ? `mongo` : `postgres`;
887
+ const redisDepends = needsRedis
888
+ ? `\n redis:\n condition: service_healthy`
889
+ : "";
890
+ const apiService = `
891
+ api:
892
+ build:
893
+ context: ..
894
+ dockerfile: apps/api/Dockerfile
895
+ restart: unless-stopped
896
+ ports:
897
+ - "3000:3000"
898
+ environment:
899
+ NODE_ENV: production
900
+ PORT: 3000
901
+ ${isMongo ? `MONGODB_URI: mongodb://mongo:27017/${a.projectName}` : `DATABASE_URL: postgres://saas:saaspassword@${dbDepends}:5432/saas`}
902
+ ${needsRedis ? "REDIS_URL: redis://redis:6379" : ""}
903
+ depends_on:
904
+ ${dbDepends}:
905
+ condition: service_healthy${redisDepends}
906
+ `;
907
+ const workerService = a.includeWorker
908
+ ? `
909
+ worker:
910
+ build:
911
+ context: ..
912
+ dockerfile: apps/worker/Dockerfile
913
+ restart: unless-stopped
914
+ environment:
915
+ NODE_ENV: production
916
+ ${isMongo ? `MONGODB_URI: mongodb://mongo:27017/${a.projectName}` : `DATABASE_URL: postgres://saas:saaspassword@${dbDepends}:5432/saas`}
917
+ REDIS_URL: redis://redis:6379
918
+ depends_on:
919
+ ${dbDepends}:
920
+ condition: service_healthy
921
+ redis:
922
+ condition: service_healthy
923
+ `
924
+ : "";
925
+ const webService = a.includeWeb
926
+ ? `
927
+ web:
928
+ build:
929
+ context: ..
930
+ dockerfile: apps/web/Dockerfile
931
+ restart: unless-stopped
932
+ ports:
933
+ - "3001:3001"
934
+ environment:
935
+ NODE_ENV: production
936
+ NEXT_PUBLIC_API_URL: http://api:3000
937
+ depends_on:
938
+ - api
939
+ `
940
+ : "";
941
+ const volumes = [
942
+ isMongo ? " mongo_data:" : " postgres_data:",
943
+ needsRedis ? " redis_data:" : "",
944
+ a.includeObservability
945
+ ? " loki_data:\n prometheus_data:\n grafana_data:"
946
+ : "",
947
+ ]
948
+ .filter(Boolean)
949
+ .join("\n");
950
+ return `services:
951
+ ${mongoService}${redisService}${obsServices}${apiService}${workerService}${webService}
952
+ volumes:
953
+ ${volumes}
954
+ `;
955
+ }
956
+ // ─── Dockerfile templates ─────────────────────────────────────────────────────
957
+ export function apiDockerfile(a) {
958
+ const installCmd = a.packageManager === "bun"
959
+ ? "RUN bun install --frozen-lockfile"
960
+ : a.packageManager === "pnpm"
961
+ ? "RUN pnpm install --frozen-lockfile"
962
+ : "RUN npm ci";
963
+ const buildCmd = a.packageManager === "bun"
964
+ ? "RUN bun run build --filter=@saas/api"
965
+ : a.packageManager === "pnpm"
966
+ ? "RUN pnpm run build --filter=@saas/api"
967
+ : "RUN npm run build --filter=@saas/api";
968
+ const baseImage = a.packageManager === "bun" ? "oven/bun:1" : "node:22-alpine";
969
+ return `FROM ${baseImage} AS base
970
+ WORKDIR /app
971
+
972
+ FROM base AS builder
973
+ COPY package.json turbo.json ./
974
+ COPY apps/api/package.json ./apps/api/
975
+ COPY packages/ ./packages/
976
+ ${installCmd}
977
+ COPY . .
978
+ ${buildCmd}
979
+
980
+ FROM node:22-alpine AS runner
981
+ WORKDIR /app/apps/api
982
+ COPY --from=builder /app/apps/api/dist ./dist
983
+ COPY --from=builder /app/apps/api/package.json ./
984
+ RUN npm install --production
985
+ EXPOSE 3000
986
+ CMD ["node", "dist/index.js"]
987
+ `;
988
+ }
989
+ export function workerDockerfile(a) {
990
+ const installCmd = a.packageManager === "bun"
991
+ ? "RUN bun install --frozen-lockfile"
992
+ : a.packageManager === "pnpm"
993
+ ? "RUN pnpm install --frozen-lockfile"
994
+ : "RUN npm ci";
995
+ const buildCmd = a.packageManager === "bun"
996
+ ? "RUN bun run build --filter=@saas/worker"
997
+ : a.packageManager === "pnpm"
998
+ ? "RUN pnpm run build --filter=@saas/worker"
999
+ : "RUN npm run build --filter=@saas/worker";
1000
+ const baseImage = a.packageManager === "bun" ? "oven/bun:1" : "node:22-alpine";
1001
+ return `FROM ${baseImage} AS base
1002
+ WORKDIR /app
1003
+
1004
+ FROM base AS builder
1005
+ COPY package.json turbo.json ./
1006
+ COPY apps/worker/package.json ./apps/worker/
1007
+ COPY packages/ ./packages/
1008
+ ${installCmd}
1009
+ COPY . .
1010
+ ${buildCmd}
1011
+
1012
+ FROM node:22-alpine AS runner
1013
+ WORKDIR /app/apps/worker
1014
+ COPY --from=builder /app/apps/worker/dist ./dist
1015
+ COPY --from=builder /app/apps/worker/package.json ./
1016
+ RUN npm install --production
1017
+ CMD ["node", "dist/index.js"]
1018
+ `;
1019
+ }
1020
+ // ─── README ───────────────────────────────────────────────────────────────────
1021
+ export function rootReadme(a) {
1022
+ const pm = a.packageManager;
1023
+ const isMongo = a.database === "mongodb-mongoose";
1024
+ return `# ${a.projectName}
1025
+
1026
+ > Scaffolded with [create-saas-app](https://github.com/you/create-saas-app) 🚀
1027
+
1028
+ A production-ready **Multi-Tenant SaaS** Turborepo monorepo.
1029
+
1030
+ ## Stack
1031
+
1032
+ | Layer | Choice |
1033
+ |-------|--------|
1034
+ | Runtime | Node.js 22+ |
1035
+ | Framework | Express |
1036
+ | Database | ${a.database} |
1037
+ | Queue | ${a.includeQueue ? "BullMQ + Redis" : "—"} |
1038
+ | Auth | ${a.includeAuth ? "JWT (jsonwebtoken + bcryptjs)" : "—"} |
1039
+ | Rate Limiting | ${a.rateLimit === "none" ? "—" : a.rateLimit === "redis" ? "Redis-backed (rate-limit-redis)" : "In-memory (express-rate-limit)"} |
1040
+ | Monorepo | Turborepo |
1041
+ | Build | TypeScript |
1042
+
1043
+ ## Getting Started
1044
+
1045
+ \`\`\`bash
1046
+ # 1. Install dependencies
1047
+ ${pm} install
1048
+
1049
+ # 2. Start infrastructure
1050
+ docker compose -f docker/docker-compose.yml up -d
1051
+
1052
+ # 3. Copy & fill in env vars
1053
+ cp apps/api/.env.example apps/api/.env
1054
+ ${isMongo ? "" : "\n# 4. Run migrations\n" + pm + " run db:migrate"}
1055
+
1056
+ # ${isMongo ? "4" : "5"}. Start dev servers
1057
+ ${pm} run dev
1058
+ \`\`\`
1059
+
1060
+ ## Project Structure
1061
+
1062
+ \`\`\`
1063
+ ${a.projectName}/
1064
+ ├── apps/
1065
+ │ ├── api/ # Express REST API
1066
+ ${a.includeWorker ? "│ └── worker/ # BullMQ background worker\n" : ""}└── packages/
1067
+ ├── config/ # Shared env config
1068
+ ├── database/ # DB client & models
1069
+ ├── logger/ # Pino logger
1070
+ ${a.includeAuth ? " ├── auth/ # JWT auth utilities\n" : ""}${a.includeQueue ? " ├── queue/ # BullMQ queue definitions\n ├── redis/ # Redis client\n" : ""} ├── types/ # Shared TypeScript types
1071
+ └── typescript-config/
1072
+ \`\`\`
1073
+
1074
+ ## Scripts
1075
+
1076
+ | Command | Description |
1077
+ |---------|-------------|
1078
+ | \`${pm} run dev\` | Start all apps in watch mode |
1079
+ | \`${pm} run build\` | Build all packages |
1080
+ | \`${pm} run lint\` | Lint all packages |
1081
+ | \`${pm} run check-types\` | Type-check all packages |
1082
+ ${isMongo ? "" : "| `" + pm + " run db:generate` | Generate DB migrations |\n| `" + pm + " run db:migrate` | Run DB migrations |"}
1083
+
1084
+ ---
1085
+
1086
+ _Generated by \`create-saas-app\`_
1087
+ `;
1088
+ }
1089
+ // ─── Drizzle config ───────────────────────────────────────────────────────────
1090
+ export function drizzleConfigTs(db) {
1091
+ if (db === "sqlite-drizzle") {
1092
+ return `import { defineConfig } from "drizzle-kit";
1093
+
1094
+ export default defineConfig({
1095
+ schema: "./src/schema.ts",
1096
+ out: "./drizzle",
1097
+ dialect: "sqlite",
1098
+ dbCredentials: {
1099
+ url: "./local.db",
1100
+ },
1101
+ });
1102
+ `;
1103
+ }
1104
+ // postgres-drizzle
1105
+ return `import { defineConfig } from "drizzle-kit";
1106
+
1107
+ export default defineConfig({
1108
+ schema: "./src/schema.ts",
1109
+ out: "./drizzle",
1110
+ dialect: "postgresql",
1111
+ dbCredentials: {
1112
+ url: process.env["DATABASE_URL"]!,
1113
+ },
1114
+ });
1115
+ `;
1116
+ }
1117
+ export function drizzleSchemaTs(db) {
1118
+ if (db === "sqlite-drizzle") {
1119
+ return `import { text, integer, sqliteTable } from "drizzle-orm/sqlite-core";
1120
+
1121
+ export const tenants = sqliteTable("tenants", {
1122
+ id: text("id").primaryKey(),
1123
+ name: text("name").notNull(),
1124
+ slug: text("slug").notNull().unique(),
1125
+ plan: text("plan", { enum: ["free", "pro", "enterprise"] }).notNull().default("free"),
1126
+ createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
1127
+ });
1128
+
1129
+ export type Tenant = typeof tenants.$inferSelect;
1130
+ export type NewTenant = typeof tenants.$inferInsert;
1131
+ `;
1132
+ }
1133
+ // postgres-drizzle
1134
+ return `import { pgTable, text, timestamp, pgEnum } from "drizzle-orm/pg-core";
1135
+
1136
+ export const planEnum = pgEnum("plan", ["free", "pro", "enterprise"]);
1137
+
1138
+ export const tenants = pgTable("tenants", {
1139
+ id: text("id").primaryKey(),
1140
+ name: text("name").notNull(),
1141
+ slug: text("slug").notNull().unique(),
1142
+ plan: planEnum("plan").notNull().default("free"),
1143
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
1144
+ });
1145
+
1146
+ export type Tenant = typeof tenants.$inferSelect;
1147
+ export type NewTenant = typeof tenants.$inferInsert;
1148
+ `;
1149
+ }
1150
+ // ─── Prisma schema ────────────────────────────────────────────────────────────
1151
+ export function prismaSchemaTemplate(projectName) {
1152
+ return `// This is your Prisma schema file.
1153
+ // Learn more about it in the docs: https://pris.ly/d/prisma-schema
1154
+
1155
+ generator client {
1156
+ provider = "prisma-client-js"
1157
+ }
1158
+
1159
+ datasource db {
1160
+ provider = "postgresql"
1161
+ url = env("DATABASE_URL")
1162
+ }
1163
+
1164
+ enum Plan {
1165
+ free
1166
+ pro
1167
+ enterprise
1168
+ }
1169
+
1170
+ model Tenant {
1171
+ id String @id @default(cuid())
1172
+ name String
1173
+ slug String @unique
1174
+ plan Plan @default(free)
1175
+ createdAt DateTime @default(now()) @map("created_at")
1176
+
1177
+ @@map("tenants")
1178
+ }
1179
+ `;
1180
+ }
1181
+ // ─── Vitest config ────────────────────────────────────────────────────────────
1182
+ export function vitestConfigTs() {
1183
+ return `import { defineConfig } from "vitest/config";
1184
+
1185
+ export default defineConfig({
1186
+ test: {
1187
+ globals: true,
1188
+ environment: "node",
1189
+ coverage: {
1190
+ reporter: ["text", "lcov"],
1191
+ exclude: ["node_modules/", "dist/"],
1192
+ },
1193
+ },
1194
+ });
1195
+ `;
1196
+ }
1197
+ // ─── ESLint flat config ───────────────────────────────────────────────────────
1198
+ export function eslintConfigTs() {
1199
+ return `import js from "@eslint/js";
1200
+ import tsPlugin from "@typescript-eslint/eslint-plugin";
1201
+ import tsParser from "@typescript-eslint/parser";
1202
+ import globals from "globals";
1203
+
1204
+ /** @type {import("eslint").Linter.FlatConfig[]} */
1205
+ export default [
1206
+ js.configs.recommended,
1207
+ {
1208
+ files: ["**/*.ts"],
1209
+ languageOptions: {
1210
+ parser: tsParser,
1211
+ parserOptions: { project: true },
1212
+ globals: { ...globals.node },
1213
+ },
1214
+ plugins: { "@typescript-eslint": tsPlugin },
1215
+ rules: {
1216
+ ...tsPlugin.configs.recommended.rules,
1217
+ "@typescript-eslint/no-explicit-any": "warn",
1218
+ "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
1219
+ },
1220
+ },
1221
+ {
1222
+ ignores: ["dist/", "node_modules/", "coverage/"],
1223
+ },
1224
+ ];
1225
+ `;
1226
+ }
1227
+ export function eslintDevDeps() {
1228
+ return {
1229
+ "@eslint/js": "^9.0.0",
1230
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
1231
+ "@typescript-eslint/parser": "^8.0.0",
1232
+ eslint: "^9.0.0",
1233
+ globals: "^15.0.0",
1234
+ };
1235
+ }
1236
+ // ─── Prettier config ──────────────────────────────────────────────────────────
1237
+ export function prettierRc() {
1238
+ return JSON.stringify({
1239
+ semi: true,
1240
+ singleQuote: false,
1241
+ tabWidth: 2,
1242
+ trailingComma: "all",
1243
+ printWidth: 100,
1244
+ }, null, 2);
1245
+ }
1246
+ // ─── Tenant middleware ────────────────────────────────────────────────────────
1247
+ export function tenantMiddlewareTs() {
1248
+ return `import type { RequestHandler } from "express";
1249
+
1250
+ /**
1251
+ * Resolves the tenant from the request.
1252
+ * Extend this to look up the tenant from a DB using slug / subdomain / header.
1253
+ */
1254
+ export function tenantMiddleware(): RequestHandler {
1255
+ return async (req, res, next) => {
1256
+ // Option A: resolve from subdomain (e.g. acme.yoursaas.com → "acme")
1257
+ // const host = req.hostname;
1258
+ // const slug = host.split(".")[0];
1259
+
1260
+ // Option B: resolve from a custom header X-Tenant-Slug: acme
1261
+ const slug = req.headers["x-tenant-slug"] as string | undefined;
1262
+
1263
+ if (!slug) {
1264
+ res.status(400).json({ success: false, error: { message: "Missing tenant identifier" } });
1265
+ return;
1266
+ }
1267
+
1268
+ // TODO: look up the tenant in your DB and attach it to req
1269
+ // const tenant = await db.query.tenants.findFirst({ where: eq(tenants.slug, slug) });
1270
+ // if (!tenant) { res.status(404).json({ ... }); return; }
1271
+ // (req as any).tenant = tenant;
1272
+
1273
+ // For now, just pass the slug along so you can start building
1274
+ (req as any).tenantSlug = slug;
1275
+ next();
1276
+ };
1277
+ }
1278
+ `;
1279
+ }
1280
+ // ─── Worker .env.example ─────────────────────────────────────────────────────
1281
+ export function workerEnvExampleTemplate(a) {
1282
+ const isMongo = a.database === "mongodb-mongoose";
1283
+ const dbLine = isMongo
1284
+ ? `MONGODB_URI=mongodb://localhost:27017/${a.projectName}`
1285
+ : `DATABASE_URL=postgres://saas:saaspassword@localhost:5432/saas`;
1286
+ return `# ─── Application ─────────────────────────────────────────────────────────────
1287
+ NODE_ENV=development
1288
+
1289
+ # ─── Database ─────────────────────────────────────────────────────────────────
1290
+ ${dbLine}
1291
+
1292
+ # ─── Redis ────────────────────────────────────────────────────────────────────
1293
+ REDIS_URL=redis://localhost:6379
1294
+
1295
+ # ─── Observability ────────────────────────────────────────────────────────────
1296
+ LOG_LEVEL=info
1297
+ `;
1298
+ }
1299
+ // ─── Grafana provisioning ─────────────────────────────────────────────────────
1300
+ export function grafanaDatasourceYaml() {
1301
+ return `apiVersion: 1
1302
+
1303
+ datasources:
1304
+ - name: Prometheus
1305
+ type: prometheus
1306
+ access: proxy
1307
+ url: http://prometheus:9090
1308
+ isDefault: true
1309
+ editable: true
1310
+
1311
+ - name: Loki
1312
+ type: loki
1313
+ access: proxy
1314
+ url: http://loki:3100
1315
+ editable: true
1316
+ `;
1317
+ }
1318
+ // ─── Next.js 15 web app ───────────────────────────────────────────────────────
1319
+ export function webPackageJson(a) {
1320
+ const deps = {
1321
+ next: "15.2.0",
1322
+ react: "^19.0.0",
1323
+ "react-dom": "^19.0.0",
1324
+ "@saas/config": "*",
1325
+ "@saas/types": "*",
1326
+ };
1327
+ if (a.includeAuth)
1328
+ deps["@saas/auth"] = "*";
1329
+ if (a.emailProvider !== "none")
1330
+ deps["@saas/email"] = "*";
1331
+ return JSON.stringify({
1332
+ name: "@saas/web",
1333
+ version: "0.1.0",
1334
+ private: true,
1335
+ scripts: {
1336
+ dev: "next dev --turbopack",
1337
+ build: "next build",
1338
+ start: "next start",
1339
+ lint: "next lint",
1340
+ "check-types": "tsc --noEmit",
1341
+ },
1342
+ dependencies: deps,
1343
+ devDependencies: {
1344
+ "@saas/typescript-config": "*",
1345
+ "@types/node": "^22.0.0",
1346
+ "@types/react": "^19.0.0",
1347
+ "@types/react-dom": "^19.0.0",
1348
+ typescript: "5.7.3",
1349
+ },
1350
+ }, null, 2);
1351
+ }
1352
+ export function webNextConfig() {
1353
+ return `import type { NextConfig } from "next";
1354
+
1355
+ const nextConfig: NextConfig = {
1356
+ output: "standalone",
1357
+ transpilePackages: [
1358
+ "@saas/config",
1359
+ "@saas/types",
1360
+ "@saas/auth",
1361
+ "@saas/email",
1362
+ ],
1363
+ };
1364
+
1365
+ export default nextConfig;
1366
+ `;
1367
+ }
1368
+ export function webTsconfig() {
1369
+ return JSON.stringify({
1370
+ extends: "@saas/typescript-config/base.json",
1371
+ compilerOptions: {
1372
+ target: "ES2017",
1373
+ lib: ["dom", "dom.iterable", "esnext"],
1374
+ allowJs: true,
1375
+ skipLibCheck: true,
1376
+ strict: true,
1377
+ noEmit: true,
1378
+ esModuleInterop: true,
1379
+ module: "esnext",
1380
+ moduleResolution: "bundler",
1381
+ resolveJsonModule: true,
1382
+ isolatedModules: true,
1383
+ jsx: "preserve",
1384
+ incremental: true,
1385
+ plugins: [{ name: "next" }],
1386
+ paths: { "@/*": ["./src/*"] },
1387
+ },
1388
+ include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
1389
+ exclude: ["node_modules"],
1390
+ }, null, 2);
1391
+ }
1392
+ export function webRootLayout(a) {
1393
+ return `import type { Metadata } from "next";
1394
+ import { Inter } from "next/font/google";
1395
+ import "./globals.css";
1396
+
1397
+ const inter = Inter({ subsets: ["latin"] });
1398
+
1399
+ export const metadata: Metadata = {
1400
+ title: "${a.projectName}",
1401
+ description: "Multi-Tenant SaaS Application",
1402
+ };
1403
+
1404
+ export default function RootLayout({
1405
+ children,
1406
+ }: {
1407
+ children: React.ReactNode;
1408
+ }) {
1409
+ return (
1410
+ <html lang="en">
1411
+ <body className={inter.className}>{children}</body>
1412
+ </html>
1413
+ );
1414
+ }
1415
+ `;
1416
+ }
1417
+ export function webGlobalsCss() {
1418
+ return `@import "tailwindcss";
1419
+ `;
1420
+ }
1421
+ export function webHomePage(a) {
1422
+ return `export default function HomePage() {
1423
+ return (
1424
+ <main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 680, margin: "80px auto", padding: "0 1rem" }}>
1425
+ <h1 style={{ fontSize: "2.5rem", fontWeight: 700, marginBottom: "0.5rem" }}>
1426
+ Welcome to <span style={{ color: "#0070f3" }}>${a.projectName}</span>
1427
+ </h1>
1428
+ <p style={{ color: "#666", fontSize: "1.125rem", marginBottom: "2rem" }}>
1429
+ Your production-ready Multi-Tenant SaaS platform.
1430
+ </p>
1431
+
1432
+ <div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
1433
+ <a
1434
+ href="/dashboard"
1435
+ style={{
1436
+ padding: "0.75rem 1.5rem",
1437
+ background: "#0070f3",
1438
+ color: "#fff",
1439
+ borderRadius: 8,
1440
+ textDecoration: "none",
1441
+ fontWeight: 600,
1442
+ }}
1443
+ >
1444
+ Dashboard →
1445
+ </a>
1446
+ <a
1447
+ href="/login"
1448
+ style={{
1449
+ padding: "0.75rem 1.5rem",
1450
+ background: "#f5f5f5",
1451
+ color: "#333",
1452
+ borderRadius: 8,
1453
+ textDecoration: "none",
1454
+ fontWeight: 600,
1455
+ }}
1456
+ >
1457
+ Sign In
1458
+ </a>
1459
+ </div>
1460
+ </main>
1461
+ );
1462
+ }
1463
+ `;
1464
+ }
1465
+ export function webDashboardPage() {
1466
+ return `// Protected — add your auth check here (e.g. read a cookie, call the API)
1467
+ export default function DashboardPage() {
1468
+ return (
1469
+ <main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 900, margin: "40px auto", padding: "0 1rem" }}>
1470
+ <h1 style={{ fontSize: "1.75rem", fontWeight: 700 }}>Dashboard</h1>
1471
+ <p style={{ color: "#666" }}>You are signed in. Build your SaaS here.</p>
1472
+
1473
+ <div
1474
+ style={{
1475
+ background: "#f9f9f9",
1476
+ border: "1px solid #eee",
1477
+ borderRadius: 12,
1478
+ padding: "2rem",
1479
+ marginTop: "2rem",
1480
+ }}
1481
+ >
1482
+ <p style={{ margin: 0, color: "#888" }}>
1483
+ 📦 Workspace ready — start adding your features.
1484
+ </p>
1485
+ </div>
1486
+ </main>
1487
+ );
1488
+ }
1489
+ `;
1490
+ }
1491
+ export function webLoginPage() {
1492
+ return `"use client";
1493
+ import { useState } from "react";
1494
+
1495
+ export default function LoginPage() {
1496
+ const [email, setEmail] = useState("");
1497
+ const [password, setPassword] = useState("");
1498
+ const [loading, setLoading] = useState(false);
1499
+ const [error, setError] = useState<string | null>(null);
1500
+
1501
+ async function handleSubmit(e: React.FormEvent) {
1502
+ e.preventDefault();
1503
+ setLoading(true);
1504
+ setError(null);
1505
+ try {
1506
+ const res = await fetch(
1507
+ \`\${process.env["NEXT_PUBLIC_API_URL"]}/api/v1/auth/login\`,
1508
+ {
1509
+ method: "POST",
1510
+ headers: { "Content-Type": "application/json" },
1511
+ body: JSON.stringify({ email, password }),
1512
+ }
1513
+ );
1514
+ const data = await res.json();
1515
+ if (!res.ok) throw new Error(data.error?.message ?? "Login failed");
1516
+ // TODO: store token (cookie / localStorage) and redirect
1517
+ window.location.href = "/dashboard";
1518
+ } catch (err: unknown) {
1519
+ setError(err instanceof Error ? err.message : "Login failed");
1520
+ } finally {
1521
+ setLoading(false);
1522
+ }
1523
+ }
1524
+
1525
+ return (
1526
+ <main
1527
+ style={{
1528
+ fontFamily: "system-ui, sans-serif",
1529
+ maxWidth: 400,
1530
+ margin: "100px auto",
1531
+ padding: "0 1rem",
1532
+ }}
1533
+ >
1534
+ <h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1.5rem" }}>
1535
+ Sign in
1536
+ </h1>
1537
+ {error && (
1538
+ <p style={{ color: "#e00", marginBottom: "1rem", fontSize: "0.9rem" }}>
1539
+ {error}
1540
+ </p>
1541
+ )}
1542
+ <form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
1543
+ <input
1544
+ type="email"
1545
+ placeholder="Email"
1546
+ value={email}
1547
+ onChange={(e) => setEmail(e.target.value)}
1548
+ required
1549
+ style={{ padding: "0.75rem", borderRadius: 8, border: "1px solid #ddd", fontSize: "1rem" }}
1550
+ />
1551
+ <input
1552
+ type="password"
1553
+ placeholder="Password"
1554
+ value={password}
1555
+ onChange={(e) => setPassword(e.target.value)}
1556
+ required
1557
+ style={{ padding: "0.75rem", borderRadius: 8, border: "1px solid #ddd", fontSize: "1rem" }}
1558
+ />
1559
+ <button
1560
+ type="submit"
1561
+ disabled={loading}
1562
+ style={{
1563
+ padding: "0.75rem",
1564
+ background: "#0070f3",
1565
+ color: "#fff",
1566
+ border: "none",
1567
+ borderRadius: 8,
1568
+ fontWeight: 600,
1569
+ fontSize: "1rem",
1570
+ cursor: "pointer",
1571
+ opacity: loading ? 0.7 : 1,
1572
+ }}
1573
+ >
1574
+ {loading ? "Signing in…" : "Sign in"}
1575
+ </button>
1576
+ </form>
1577
+ </main>
1578
+ );
1579
+ }
1580
+ `;
1581
+ }
1582
+ export function webMiddleware() {
1583
+ return `import { NextResponse } from "next/server";
1584
+ import type { NextRequest } from "next/server";
1585
+
1586
+ const PUBLIC_PATHS = ["/", "/login", "/api/auth"];
1587
+
1588
+ export function middleware(request: NextRequest) {
1589
+ const { pathname } = request.nextUrl;
1590
+
1591
+ const isPublic = PUBLIC_PATHS.some(
1592
+ (p) => pathname === p || pathname.startsWith("/api/auth")
1593
+ );
1594
+
1595
+ if (isPublic) return NextResponse.next();
1596
+
1597
+ // Read the JWT stored in an httpOnly cookie after login
1598
+ const token = request.cookies.get("auth-token")?.value;
1599
+
1600
+ if (!token) {
1601
+ const loginUrl = request.nextUrl.clone();
1602
+ loginUrl.pathname = "/login";
1603
+ return NextResponse.redirect(loginUrl);
1604
+ }
1605
+
1606
+ // NOTE: full JWT verification should happen in your API, not here.
1607
+ // Middleware only does a lightweight "token present" check for UX.
1608
+ return NextResponse.next();
1609
+ }
1610
+
1611
+ export const config = {
1612
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
1613
+ };
1614
+ `;
1615
+ }
1616
+ export function webEnvExample(a) {
1617
+ return `# ─── API connection ───────────────────────────────────────────────────────────
1618
+ NEXT_PUBLIC_API_URL=http://localhost:3000
1619
+
1620
+ # ─── Auth ─────────────────────────────────────────────────────────────────────
1621
+ NEXT_PUBLIC_APP_URL=http://localhost:3001
1622
+ ${a.includePayments ? `\n# ─── Razorpay ────────────────────────────────────────────────────────────────\nNEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_test_xxxxxxxxxxxx` : ""}
1623
+ `;
1624
+ }
1625
+ export function webDockerfile(a) {
1626
+ const installCmd = a.packageManager === "bun"
1627
+ ? "RUN bun install --frozen-lockfile"
1628
+ : a.packageManager === "pnpm"
1629
+ ? "RUN pnpm install --frozen-lockfile"
1630
+ : "RUN npm ci";
1631
+ const baseImage = a.packageManager === "bun" ? "oven/bun:1" : "node:22-alpine";
1632
+ return `FROM ${baseImage} AS base
1633
+ WORKDIR /app
1634
+
1635
+ FROM base AS builder
1636
+ ENV NEXT_TELEMETRY_DISABLED=1
1637
+ COPY package.json turbo.json ./
1638
+ COPY apps/web/package.json ./apps/web/
1639
+ COPY packages/ ./packages/
1640
+ ${installCmd}
1641
+ COPY . .
1642
+ RUN npm run build --filter=@saas/web
1643
+
1644
+ FROM node:22-alpine AS runner
1645
+ WORKDIR /app/apps/web
1646
+ ENV NODE_ENV=production
1647
+ ENV NEXT_TELEMETRY_DISABLED=1
1648
+ COPY --from=builder /app/apps/web/.next/standalone ./
1649
+ COPY --from=builder /app/apps/web/.next/static ./.next/static
1650
+ COPY --from=builder /app/apps/web/public ./public
1651
+ EXPOSE 3001
1652
+ CMD ["node", "server.js"]
1653
+ `;
1654
+ }
1655
+ // ─── Razorpay payments package ────────────────────────────────────────────────
1656
+ export function paymentsPackageJson() {
1657
+ return JSON.stringify({
1658
+ name: "@saas/payments",
1659
+ version: "0.1.0",
1660
+ private: true,
1661
+ type: "module",
1662
+ main: "./src/index.ts",
1663
+ exports: { ".": "./src/index.ts" },
1664
+ scripts: { "check-types": "tsc --noEmit" },
1665
+ dependencies: {
1666
+ razorpay: "^2.9.4",
1667
+ "@saas/config": "*",
1668
+ "@saas/logger": "*",
1669
+ },
1670
+ devDependencies: {
1671
+ "@saas/typescript-config": "*",
1672
+ "@types/node": "^22.0.0",
1673
+ typescript: "5.7.3",
1674
+ },
1675
+ }, null, 2);
1676
+ }
1677
+ export function paymentsIndexTs() {
1678
+ return `import Razorpay from "razorpay";
1679
+ import crypto from "node:crypto";
1680
+ import { createLogger } from "@saas/logger";
1681
+
1682
+ const logger = createLogger("payments");
1683
+
1684
+ let instance: Razorpay | null = null;
1685
+
1686
+ export function getRazorpayClient(): Razorpay {
1687
+ if (!instance) {
1688
+ const keyId = process.env["RAZORPAY_KEY_ID"];
1689
+ const keySecret = process.env["RAZORPAY_KEY_SECRET"];
1690
+ if (!keyId || !keySecret) {
1691
+ throw new Error("RAZORPAY_KEY_ID and RAZORPAY_KEY_SECRET must be set");
1692
+ }
1693
+ instance = new Razorpay({ key_id: keyId, key_secret: keySecret });
1694
+ }
1695
+ return instance;
1696
+ }
1697
+
1698
+ export interface CreateOrderOptions {
1699
+ amountInPaise: number; // e.g. 49900 = ₹499.00
1700
+ currency?: string;
1701
+ receipt?: string;
1702
+ notes?: Record<string, string>;
1703
+ }
1704
+
1705
+ export async function createOrder(opts: CreateOrderOptions) {
1706
+ const rz = getRazorpayClient();
1707
+ const order = await rz.orders.create({
1708
+ amount: opts.amountInPaise,
1709
+ currency: opts.currency ?? "INR",
1710
+ receipt: opts.receipt,
1711
+ notes: opts.notes,
1712
+ });
1713
+ logger.info({ orderId: order.id, amount: opts.amountInPaise }, "Razorpay order created");
1714
+ return order;
1715
+ }
1716
+
1717
+ /**
1718
+ * Verify Razorpay webhook signature.
1719
+ * @param rawBody - Raw request body string (before JSON.parse)
1720
+ * @param signature - Value of the X-Razorpay-Signature header
1721
+ */
1722
+ export function verifyWebhookSignature(rawBody: string, signature: string): boolean {
1723
+ const secret = process.env["RAZORPAY_WEBHOOK_SECRET"];
1724
+ if (!secret) throw new Error("RAZORPAY_WEBHOOK_SECRET is not set");
1725
+ const expected = crypto
1726
+ .createHmac("sha256", secret)
1727
+ .update(rawBody)
1728
+ .digest("hex");
1729
+ return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
1730
+ }
1731
+
1732
+ /**
1733
+ * Verify Razorpay payment signature (client-side callback verification).
1734
+ */
1735
+ export function verifyPaymentSignature(
1736
+ orderId: string,
1737
+ paymentId: string,
1738
+ signature: string,
1739
+ ): boolean {
1740
+ const secret = process.env["RAZORPAY_KEY_SECRET"];
1741
+ if (!secret) throw new Error("RAZORPAY_KEY_SECRET is not set");
1742
+ const body = \`\${orderId}|\${paymentId}\`;
1743
+ const expected = crypto
1744
+ .createHmac("sha256", secret)
1745
+ .update(body)
1746
+ .digest("hex");
1747
+ return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
1748
+ }
1749
+ `;
1750
+ }
1751
+ export function paymentsRouteTs() {
1752
+ return `import { Router } from "express";
1753
+ import { createOrder, verifyWebhookSignature, verifyPaymentSignature } from "@saas/payments";
1754
+ import { createLogger } from "@saas/logger";
1755
+ import type { RequestHandler } from "express";
1756
+
1757
+ const logger = createLogger("payments-route");
1758
+
1759
+ export function paymentsRouter(): Router {
1760
+ const router = Router();
1761
+
1762
+ // POST /payments/order — create a Razorpay order
1763
+ router.post("/order", (async (req, res) => {
1764
+ const { amountInPaise, currency, receipt, notes } = req.body as {
1765
+ amountInPaise: number;
1766
+ currency?: string;
1767
+ receipt?: string;
1768
+ notes?: Record<string, string>;
1769
+ };
1770
+
1771
+ if (!amountInPaise || amountInPaise < 100) {
1772
+ res.status(400).json({ success: false, error: { message: "Invalid amount" } });
1773
+ return;
1774
+ }
1775
+
1776
+ try {
1777
+ const order = await createOrder({ amountInPaise, currency, receipt, notes });
1778
+ res.json({ success: true, data: order });
1779
+ } catch (err) {
1780
+ logger.error({ err }, "Failed to create Razorpay order");
1781
+ res.status(500).json({ success: false, error: { message: "Failed to create order" } });
1782
+ }
1783
+ }) as RequestHandler);
1784
+
1785
+ // POST /payments/verify — verify payment after client callback
1786
+ router.post("/verify", ((req, res) => {
1787
+ const { razorpay_order_id, razorpay_payment_id, razorpay_signature } =
1788
+ req.body as {
1789
+ razorpay_order_id: string;
1790
+ razorpay_payment_id: string;
1791
+ razorpay_signature: string;
1792
+ };
1793
+
1794
+ const valid = verifyPaymentSignature(
1795
+ razorpay_order_id,
1796
+ razorpay_payment_id,
1797
+ razorpay_signature,
1798
+ );
1799
+
1800
+ if (!valid) {
1801
+ res.status(400).json({ success: false, error: { message: "Invalid payment signature" } });
1802
+ return;
1803
+ }
1804
+
1805
+ // TODO: update subscription/plan in your DB here
1806
+ logger.info({ orderId: razorpay_order_id, paymentId: razorpay_payment_id }, "Payment verified");
1807
+ res.json({ success: true, data: { paymentId: razorpay_payment_id } });
1808
+ }) as RequestHandler);
1809
+
1810
+ // POST /payments/webhook — Razorpay server-to-server events
1811
+ router.post("/webhook", ((req, res) => {
1812
+ const signature = req.headers["x-razorpay-signature"] as string | undefined;
1813
+ if (!signature) {
1814
+ res.status(400).json({ success: false, error: { message: "Missing signature" } });
1815
+ return;
1816
+ }
1817
+
1818
+ // express.raw() middleware must be applied to this route for rawBody access
1819
+ const rawBody =
1820
+ typeof req.body === "string" ? req.body : JSON.stringify(req.body);
1821
+
1822
+ try {
1823
+ const valid = verifyWebhookSignature(rawBody, signature);
1824
+ if (!valid) {
1825
+ res.status(400).json({ success: false, error: { message: "Invalid webhook signature" } });
1826
+ return;
1827
+ }
1828
+ } catch (err) {
1829
+ logger.error({ err }, "Webhook verification error");
1830
+ res.status(500).json({ success: false, error: { message: "Webhook error" } });
1831
+ return;
1832
+ }
1833
+
1834
+ const event = req.body as { event: string; payload: unknown };
1835
+ logger.info({ event: event.event }, "Razorpay webhook received");
1836
+
1837
+ // Handle events
1838
+ switch (event.event) {
1839
+ case "payment.captured":
1840
+ // TODO: activate subscription
1841
+ break;
1842
+ case "payment.failed":
1843
+ // TODO: notify user
1844
+ break;
1845
+ case "subscription.charged":
1846
+ // TODO: update subscription period
1847
+ break;
1848
+ }
1849
+
1850
+ res.json({ success: true });
1851
+ }) as RequestHandler);
1852
+
1853
+ return router;
1854
+ }
1855
+ `;
1856
+ }
1857
+ // ─── Email package ────────────────────────────────────────────────────────────
1858
+ export function emailPackageJson(provider) {
1859
+ const deps = { "@saas/config": "*" };
1860
+ const devDeps = {
1861
+ "@saas/typescript-config": "*",
1862
+ "@types/node": "^22.0.0",
1863
+ typescript: "5.7.3",
1864
+ };
1865
+ if (provider === "resend") {
1866
+ deps["resend"] = "^4.0.0";
1867
+ }
1868
+ else {
1869
+ deps["nodemailer"] = "^6.9.0";
1870
+ devDeps["@types/nodemailer"] = "^6.4.0";
1871
+ }
1872
+ return JSON.stringify({
1873
+ name: "@saas/email",
1874
+ version: "0.1.0",
1875
+ private: true,
1876
+ type: "module",
1877
+ main: "./src/index.ts",
1878
+ exports: { ".": "./src/index.ts" },
1879
+ scripts: { "check-types": "tsc --noEmit" },
1880
+ dependencies: deps,
1881
+ devDependencies: devDeps,
1882
+ }, null, 2);
1883
+ }
1884
+ export function emailIndexTs(provider) {
1885
+ if (provider === "resend") {
1886
+ return `import { Resend } from "resend";
1887
+
1888
+ let client: Resend | null = null;
1889
+
1890
+ function getClient(): Resend {
1891
+ if (!client) {
1892
+ const apiKey = process.env["RESEND_API_KEY"];
1893
+ if (!apiKey) throw new Error("RESEND_API_KEY is not set");
1894
+ client = new Resend(apiKey);
1895
+ }
1896
+ return client;
1897
+ }
1898
+
1899
+ export interface SendEmailOptions {
1900
+ to: string | string[];
1901
+ subject: string;
1902
+ html: string;
1903
+ from?: string;
1904
+ }
1905
+
1906
+ export async function sendEmail(opts: SendEmailOptions): Promise<void> {
1907
+ const from = opts.from ?? process.env["EMAIL_FROM"] ?? "noreply@yoursaas.com";
1908
+ const { error } = await getClient().emails.send({
1909
+ from,
1910
+ to: opts.to,
1911
+ subject: opts.subject,
1912
+ html: opts.html,
1913
+ });
1914
+ if (error) throw new Error(\`Failed to send email: \${error.message}\`);
1915
+ }
1916
+
1917
+ // ─── Template helpers ──────────────────────────────────────────────────────────
1918
+
1919
+ export function welcomeEmail(name: string): string {
1920
+ return \`<h1>Welcome, \${name}!</h1><p>Thanks for signing up. Let's get started.</p>\`;
1921
+ }
1922
+
1923
+ export function passwordResetEmail(resetUrl: string): string {
1924
+ return \`<h1>Reset your password</h1><p><a href="\${resetUrl}">Click here</a> to reset your password. This link expires in 1 hour.</p>\`;
1925
+ }
1926
+ `;
1927
+ }
1928
+ // nodemailer
1929
+ return `import nodemailer from "nodemailer";
1930
+
1931
+ let transporter: nodemailer.Transporter | null = null;
1932
+
1933
+ function getTransporter(): nodemailer.Transporter {
1934
+ if (!transporter) {
1935
+ transporter = nodemailer.createTransport({
1936
+ host: process.env["SMTP_HOST"] ?? "localhost",
1937
+ port: parseInt(process.env["SMTP_PORT"] ?? "587"),
1938
+ secure: process.env["SMTP_SECURE"] === "true",
1939
+ auth:
1940
+ process.env["SMTP_USER"] && process.env["SMTP_PASS"]
1941
+ ? { user: process.env["SMTP_USER"], pass: process.env["SMTP_PASS"] }
1942
+ : undefined,
1943
+ });
1944
+ }
1945
+ return transporter;
1946
+ }
1947
+
1948
+ export interface SendEmailOptions {
1949
+ to: string | string[];
1950
+ subject: string;
1951
+ html: string;
1952
+ from?: string;
1953
+ }
1954
+
1955
+ export async function sendEmail(opts: SendEmailOptions): Promise<void> {
1956
+ const from = opts.from ?? process.env["EMAIL_FROM"] ?? "noreply@yoursaas.com";
1957
+ await getTransporter().sendMail({ from, to: opts.to, subject: opts.subject, html: opts.html });
1958
+ }
1959
+
1960
+ // ─── Template helpers ──────────────────────────────────────────────────────────
1961
+
1962
+ export function welcomeEmail(name: string): string {
1963
+ return \`<h1>Welcome, \${name}!</h1><p>Thanks for signing up. Let's get started.</p>\`;
1964
+ }
1965
+
1966
+ export function passwordResetEmail(resetUrl: string): string {
1967
+ return \`<h1>Reset your password</h1><p><a href="\${resetUrl}">Click here</a> to reset your password. This link expires in 1 hour.</p>\`;
1968
+ }
1969
+ `;
1970
+ }
1971
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
1972
+ function dbDependencies(db) {
1973
+ if (db === "mongodb-mongoose")
1974
+ return { "@saas/database": "*", mongoose: "^8.0.0" };
1975
+ if (db === "postgres-drizzle")
1976
+ return {
1977
+ "@saas/database": "*",
1978
+ "drizzle-orm": "^0.40.0",
1979
+ postgres: "^3.4.5",
1980
+ };
1981
+ if (db === "postgres-prisma")
1982
+ return { "@saas/database": "*", "@prisma/client": "^5.0.0" };
1983
+ return {
1984
+ "@saas/database": "*",
1985
+ "drizzle-orm": "^0.40.0",
1986
+ "better-sqlite3": "^9.0.0",
1987
+ };
1988
+ }
1989
+ function rateLimitDependencies(r) {
1990
+ if (r === "none")
1991
+ return {};
1992
+ if (r === "redis")
1993
+ return { "express-rate-limit": "^7.0.0", "rate-limit-redis": "^4.0.0" };
1994
+ return { "express-rate-limit": "^7.0.0" };
1995
+ }
1996
+ //# sourceMappingURL=templates.js.map