@stratum-hq/create 0.2.2 → 0.3.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 ADDED
@@ -0,0 +1,1627 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import * as fs2 from "fs";
5
+ import * as path2 from "path";
6
+ import crypto2 from "node:crypto";
7
+ import { execSync as execSync2 } from "child_process";
8
+ import { fileURLToPath } from "url";
9
+
10
+ // src/matrix.ts
11
+ var POSTGRES_STRATEGIES = ["rls", "schema", "database"];
12
+ var POSTGRES_ORMS = ["prisma", "drizzle", "sequelize", "knex", "pg"];
13
+ var MONGODB_STRATEGIES = ["database", "collection"];
14
+ var MONGODB_ORMS = ["mongoose"];
15
+ var MYSQL_STRATEGIES = ["database", "table-prefix"];
16
+ var MYSQL_ORMS = ["sequelize", "knex", "pg"];
17
+ var ALL_FRAMEWORKS = ["express", "fastify", "nextjs", "hono", "nestjs", "none"];
18
+ var VALID_COMBINATIONS = {
19
+ postgres: {
20
+ strategies: POSTGRES_STRATEGIES,
21
+ orms: POSTGRES_ORMS,
22
+ frameworks: ALL_FRAMEWORKS
23
+ },
24
+ mongodb: {
25
+ strategies: MONGODB_STRATEGIES,
26
+ orms: MONGODB_ORMS,
27
+ frameworks: ALL_FRAMEWORKS
28
+ },
29
+ mysql: {
30
+ strategies: MYSQL_STRATEGIES,
31
+ orms: MYSQL_ORMS,
32
+ frameworks: ALL_FRAMEWORKS
33
+ }
34
+ };
35
+ var ALL_DATABASES = ["postgres", "mongodb", "mysql"];
36
+ var ALL_STRATEGIES = ["rls", "schema", "database", "collection", "table-prefix"];
37
+ var ALL_ORMS = ["prisma", "drizzle", "sequelize", "knex", "mongoose", "pg"];
38
+ function isValidPreset(preset) {
39
+ const config = VALID_COMBINATIONS[preset.database];
40
+ if (!config)
41
+ return false;
42
+ if (!config.strategies.includes(preset.strategy))
43
+ return false;
44
+ if (!config.orms.includes(preset.orm))
45
+ return false;
46
+ if (!config.frameworks.includes(preset.framework))
47
+ return false;
48
+ return true;
49
+ }
50
+ function parsePresetString(s) {
51
+ if (!s || typeof s !== "string")
52
+ return null;
53
+ const parts = s.toLowerCase().split("-");
54
+ let database;
55
+ let strategy;
56
+ let orm;
57
+ let framework;
58
+ if (parts.length === 4) {
59
+ [database, strategy, orm, framework] = parts;
60
+ } else if (parts.length === 5) {
61
+ database = parts[0];
62
+ strategy = `${parts[1]}-${parts[2]}`;
63
+ orm = parts[3];
64
+ framework = parts[4];
65
+ } else {
66
+ return null;
67
+ }
68
+ if (!ALL_DATABASES.includes(database))
69
+ return null;
70
+ if (!ALL_STRATEGIES.includes(strategy))
71
+ return null;
72
+ if (!ALL_ORMS.includes(orm))
73
+ return null;
74
+ if (!ALL_FRAMEWORKS.includes(framework))
75
+ return null;
76
+ return {
77
+ database,
78
+ strategy,
79
+ orm,
80
+ framework
81
+ };
82
+ }
83
+
84
+ // src/preset-project.ts
85
+ import * as fs from "fs";
86
+ import * as path from "path";
87
+ import crypto from "node:crypto";
88
+ import { execSync } from "child_process";
89
+
90
+ // src/generators/docker-compose.ts
91
+ function generatePresetDockerCompose(projectName, preset) {
92
+ const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
93
+ switch (preset.database) {
94
+ case "postgres":
95
+ return generatePostgresCompose(projectName, dbName);
96
+ case "mongodb":
97
+ return generateMongoCompose(projectName, dbName);
98
+ case "mysql":
99
+ return generateMysqlCompose(projectName, dbName);
100
+ }
101
+ }
102
+ function generatePostgresCompose(projectName, dbName) {
103
+ return `# Docker Compose for ${projectName}
104
+ # Start with: docker compose up -d
105
+ version: "3.8"
106
+
107
+ services:
108
+ db:
109
+ image: postgres:16-alpine
110
+ environment:
111
+ POSTGRES_DB: ${dbName}
112
+ POSTGRES_USER: ${dbName}
113
+ POSTGRES_PASSWORD: dev_password
114
+ ports:
115
+ - "5432:5432"
116
+ volumes:
117
+ - db_data:/var/lib/postgresql/data
118
+ - ./init.sql:/docker-entrypoint-initdb.d/init.sql
119
+ healthcheck:
120
+ test: ["CMD-SHELL", "pg_isready -U ${dbName}"]
121
+ interval: 5s
122
+ timeout: 5s
123
+ retries: 5
124
+
125
+ volumes:
126
+ db_data:
127
+ `;
128
+ }
129
+ function generateMongoCompose(projectName, dbName) {
130
+ return `# Docker Compose for ${projectName}
131
+ # Start with: docker compose up -d
132
+ version: "3.8"
133
+
134
+ services:
135
+ db:
136
+ image: mongo:7
137
+ environment:
138
+ MONGO_INITDB_ROOT_USERNAME: ${dbName}
139
+ MONGO_INITDB_ROOT_PASSWORD: dev_password
140
+ MONGO_INITDB_DATABASE: ${dbName}
141
+ ports:
142
+ - "27017:27017"
143
+ volumes:
144
+ - db_data:/data/db
145
+ healthcheck:
146
+ test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
147
+ interval: 5s
148
+ timeout: 5s
149
+ retries: 5
150
+
151
+ volumes:
152
+ db_data:
153
+ `;
154
+ }
155
+ function generateMysqlCompose(projectName, dbName) {
156
+ return `# Docker Compose for ${projectName}
157
+ # Start with: docker compose up -d
158
+ version: "3.8"
159
+
160
+ services:
161
+ db:
162
+ image: mysql:8
163
+ environment:
164
+ MYSQL_DATABASE: ${dbName}
165
+ MYSQL_USER: ${dbName}
166
+ MYSQL_PASSWORD: dev_password
167
+ MYSQL_ROOT_PASSWORD: dev_root_password
168
+ ports:
169
+ - "3306:3306"
170
+ volumes:
171
+ - db_data:/var/lib/mysql
172
+ - ./init.sql:/docker-entrypoint-initdb.d/init.sql
173
+ healthcheck:
174
+ test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
175
+ interval: 5s
176
+ timeout: 5s
177
+ retries: 5
178
+
179
+ volumes:
180
+ db_data:
181
+ `;
182
+ }
183
+
184
+ // src/generators/init-sql.ts
185
+ function generatePresetInitSql(projectName, preset) {
186
+ const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
187
+ switch (preset.database) {
188
+ case "postgres":
189
+ return generatePostgresInit(projectName, dbName, preset.strategy);
190
+ case "mongodb":
191
+ return null;
192
+ case "mysql":
193
+ return generateMysqlInit(projectName, dbName);
194
+ }
195
+ }
196
+ function generatePostgresInit(projectName, dbName, strategy) {
197
+ let rlsBlock = "";
198
+ if (strategy === "rls") {
199
+ rlsBlock = `
200
+ -- Enable Row-Level Security for tenant isolation
201
+ -- Add RLS policies to each tenant-scoped table:
202
+ --
203
+ -- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
204
+ -- CREATE POLICY tenant_isolation ON your_table
205
+ -- USING (tenant_id = current_setting('app.current_tenant')::uuid);
206
+ --
207
+ -- The Stratum db-adapters package sets app.current_tenant automatically.
208
+ `;
209
+ }
210
+ return `-- Initialize ${projectName} database
211
+ -- Enable required extensions for Stratum multi-tenancy
212
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
213
+ CREATE EXTENSION IF NOT EXISTS "ltree";
214
+
215
+ -- The ltree extension enables hierarchical tenant trees
216
+ -- uuid-ossp provides uuid_generate_v4() for tenant IDs
217
+ COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
218
+ ${rlsBlock}`;
219
+ }
220
+ function generateMysqlInit(projectName, dbName) {
221
+ return `-- Initialize ${projectName} database
222
+ -- MySQL setup for Stratum multi-tenancy
223
+
224
+ -- Ensure utf8mb4 for the database
225
+ ALTER DATABASE ${dbName} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
226
+
227
+ -- Tenant metadata table
228
+ CREATE TABLE IF NOT EXISTS _stratum_tenants (
229
+ id VARCHAR(36) PRIMARY KEY,
230
+ name VARCHAR(255) NOT NULL,
231
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
232
+ );
233
+ `;
234
+ }
235
+
236
+ // src/generators/db-setup.ts
237
+ function generateDbSetup(preset) {
238
+ switch (preset.orm) {
239
+ case "prisma":
240
+ return generatePrismaSetup(preset);
241
+ case "drizzle":
242
+ return generateDrizzleSetup(preset);
243
+ case "sequelize":
244
+ return generateSequelizeSetup(preset);
245
+ case "knex":
246
+ return generateKnexSetup(preset);
247
+ case "mongoose":
248
+ return generateMongooseSetup(preset);
249
+ case "pg":
250
+ return generatePgSetup(preset);
251
+ }
252
+ }
253
+ function generatePrismaSetup(preset) {
254
+ const provider = preset.database === "mysql" ? "mysql" : "postgresql";
255
+ const envUrl = preset.database === "mysql" ? "mysql://user:password@localhost:3306/dbname" : "postgres://user:password@localhost:5432/dbname";
256
+ return [
257
+ {
258
+ filename: "prisma/schema.prisma",
259
+ content: `// Prisma schema for Stratum multi-tenancy
260
+ generator client {
261
+ provider = "prisma-client-js"
262
+ }
263
+
264
+ datasource db {
265
+ provider = "${provider}"
266
+ url = env("DATABASE_URL")
267
+ }
268
+
269
+ model Tenant {
270
+ id String @id @default(uuid())
271
+ name String
272
+ createdAt DateTime @default(now()) @map("created_at")
273
+
274
+ @@map("tenants")
275
+ }
276
+
277
+ // Add your tenant-scoped models here.
278
+ // Each model that needs tenant isolation should have a tenantId field.
279
+ `
280
+ },
281
+ {
282
+ filename: "src/stratum-prisma.ts",
283
+ content: `// Prisma client with Stratum tenant-scoped queries
284
+ import { PrismaClient } from "@prisma/client";
285
+ import { Pool } from "pg";
286
+ import { withTenant } from "@stratum-hq/db-adapters";
287
+
288
+ const prisma = new PrismaClient();
289
+ const pool = new Pool({
290
+ connectionString: process.env.DATABASE_URL,
291
+ });
292
+
293
+ // Create a tenant-scoped Prisma client.
294
+ // All queries through this client are automatically filtered by RLS.
295
+ export function createTenantPrisma(getTenantId: () => string) {
296
+ return withTenant(prisma, getTenantId, pool);
297
+ }
298
+
299
+ // Usage:
300
+ // const tenantPrisma = createTenantPrisma(() => currentTenantId);
301
+ // const orders = await tenantPrisma.order.findMany();
302
+
303
+ export { prisma, pool };
304
+ `
305
+ }
306
+ ];
307
+ }
308
+ function generateDrizzleSetup(preset) {
309
+ const files = [];
310
+ if (preset.database === "mysql") {
311
+ files.push({
312
+ filename: "src/stratum-drizzle.ts",
313
+ content: `// Drizzle ORM with Stratum tenant-scoped queries (MySQL)
314
+ import { drizzle } from "drizzle-orm/mysql2";
315
+ import mysql from "mysql2/promise";
316
+
317
+ const connection = await mysql.createConnection({
318
+ uri: process.env.DATABASE_URL,
319
+ });
320
+
321
+ export const db = drizzle(connection);
322
+
323
+ // For tenant scoping, prefix table access with the tenant database name
324
+ // or use the @stratum-hq/mysql adapter for automatic routing.
325
+ `
326
+ });
327
+ } else {
328
+ files.push({
329
+ filename: "src/stratum-drizzle.ts",
330
+ content: `// Drizzle ORM with Stratum tenant-scoped queries
331
+ import { drizzle } from "drizzle-orm/node-postgres";
332
+ import { Pool } from "pg";
333
+ import { withTenantDrizzle } from "@stratum-hq/db-adapters";
334
+
335
+ const pool = new Pool({
336
+ connectionString: process.env.DATABASE_URL,
337
+ });
338
+
339
+ export const db = drizzle(pool);
340
+
341
+ // Create a tenant-scoped Drizzle instance.
342
+ // Queries are automatically filtered by RLS policy.
343
+ export function createTenantDb(getTenantId: () => string) {
344
+ return withTenantDrizzle(db, getTenantId, pool);
345
+ }
346
+
347
+ // Usage:
348
+ // const tenantDb = createTenantDb(() => currentTenantId);
349
+ // const rows = await tenantDb.select().from(orders);
350
+
351
+ export { pool };
352
+ `
353
+ });
354
+ }
355
+ files.push({
356
+ filename: "drizzle.config.ts",
357
+ content: `import type { Config } from "drizzle-kit";
358
+
359
+ export default {
360
+ schema: "./src/schema.ts",
361
+ out: "./drizzle",
362
+ ${preset.database === "mysql" ? 'dialect: "mysql",' : 'dialect: "postgresql",'}
363
+ dbCredentials: {
364
+ url: process.env.DATABASE_URL!,
365
+ },
366
+ } satisfies Config;
367
+ `
368
+ });
369
+ return files;
370
+ }
371
+ function generateSequelizeSetup(preset) {
372
+ const dialect = preset.database === "mysql" ? "mysql" : "postgres";
373
+ return [
374
+ {
375
+ filename: "src/stratum-sequelize.ts",
376
+ content: `// Sequelize with Stratum tenant-scoped queries
377
+ import { Sequelize, DataTypes, Model } from "sequelize";
378
+
379
+ const sequelize = new Sequelize(process.env.DATABASE_URL!, {
380
+ dialect: "${dialect}",
381
+ logging: false,
382
+ });
383
+
384
+ // Define a tenant-scoped model example
385
+ class Tenant extends Model {}
386
+ Tenant.init(
387
+ {
388
+ id: { type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 },
389
+ name: { type: DataTypes.STRING, allowNull: false },
390
+ },
391
+ { sequelize, tableName: "tenants", underscored: true },
392
+ );
393
+
394
+ // For tenant isolation, add a tenantId scope to each model:
395
+ // MyModel.addScope("tenant", (tenantId) => ({ where: { tenant_id: tenantId } }));
396
+ // const rows = await MyModel.scope({ method: ["tenant", currentTenantId] }).findAll();
397
+
398
+ export { sequelize, Tenant };
399
+ `
400
+ }
401
+ ];
402
+ }
403
+ function generateKnexSetup(preset) {
404
+ const client = preset.database === "mysql" ? "mysql2" : "pg";
405
+ return [
406
+ {
407
+ filename: "knexfile.ts",
408
+ content: `// Knex configuration for Stratum
409
+ import type { Knex } from "knex";
410
+
411
+ const config: Knex.Config = {
412
+ client: "${client}",
413
+ connection: process.env.DATABASE_URL,
414
+ migrations: {
415
+ directory: "./migrations",
416
+ extension: "ts",
417
+ },
418
+ };
419
+
420
+ export default config;
421
+ `
422
+ },
423
+ {
424
+ filename: "src/stratum-knex.ts",
425
+ content: `// Knex with Stratum tenant-scoped queries
426
+ import Knex from "knex";
427
+ import config from "../knexfile.js";
428
+
429
+ const knex = Knex(config);
430
+
431
+ // Create a tenant-scoped query builder.
432
+ // For RLS strategy, set the session variable before queries.
433
+ export async function withTenantScope(tenantId: string, fn: (db: typeof knex) => Promise<void>) {
434
+ ${preset.database === "postgres" ? ` await knex.raw("SET app.current_tenant = ?", [tenantId]);` : ` // For ${preset.database}, scope queries by tenant_id column`}
435
+ try {
436
+ await fn(knex);
437
+ } finally {
438
+ ${preset.database === "postgres" ? ` await knex.raw("RESET app.current_tenant");` : ` // Scope cleanup not needed for column-based isolation`}
439
+ }
440
+ }
441
+
442
+ export { knex };
443
+ `
444
+ }
445
+ ];
446
+ }
447
+ function generateMongooseSetup(_preset) {
448
+ return [
449
+ {
450
+ filename: "src/stratum-mongoose.ts",
451
+ content: `// Mongoose with Stratum multi-tenant support
452
+ import mongoose from "mongoose";
453
+ import { createTenantConnection } from "@stratum-hq/mongodb";
454
+
455
+ // Main connection (used for tenant metadata)
456
+ const mainConnection = mongoose.createConnection(
457
+ process.env.MONGODB_URI || "mongodb://localhost:27017/main",
458
+ );
459
+
460
+ // Create a tenant-scoped connection.
461
+ // Each tenant gets its own database (database-per-tenant strategy)
462
+ // or collection prefix (collection strategy).
463
+ export function getTenantConnection(tenantId: string) {
464
+ return createTenantConnection(mainConnection, tenantId);
465
+ }
466
+
467
+ // Define schemas that work across tenant connections
468
+ export const TenantSchema = new mongoose.Schema({
469
+ name: { type: String, required: true },
470
+ createdAt: { type: Date, default: Date.now },
471
+ });
472
+
473
+ // Usage:
474
+ // const conn = getTenantConnection("tenant-abc");
475
+ // const Order = conn.model("Order", OrderSchema);
476
+ // const orders = await Order.find();
477
+
478
+ export { mainConnection };
479
+ `
480
+ }
481
+ ];
482
+ }
483
+ function generatePgSetup(preset) {
484
+ if (preset.database === "mysql") {
485
+ return [
486
+ {
487
+ filename: "src/stratum-db.ts",
488
+ content: `// Raw MySQL client with Stratum tenant-scoped queries
489
+ import mysql from "mysql2/promise";
490
+
491
+ const pool = mysql.createPool({
492
+ uri: process.env.DATABASE_URL,
493
+ });
494
+
495
+ // Execute a query scoped to a tenant by filtering on tenant_id
496
+ export async function tenantQuery(tenantId: string, sql: string, params: unknown[] = []) {
497
+ const [rows] = await pool.execute(sql, [...params, tenantId]);
498
+ return rows;
499
+ }
500
+
501
+ // Usage:
502
+ // const orders = await tenantQuery("tenant-abc", "SELECT * FROM orders WHERE tenant_id = ?");
503
+
504
+ export { pool };
505
+ `
506
+ }
507
+ ];
508
+ }
509
+ return [
510
+ {
511
+ filename: "src/stratum-db.ts",
512
+ content: `// PostgreSQL client with Stratum tenant-scoped queries
513
+ import { Pool } from "pg";
514
+ import { createTenantPool } from "@stratum-hq/db-adapters";
515
+
516
+ const pool = new Pool({
517
+ connectionString: process.env.DATABASE_URL,
518
+ });
519
+
520
+ // Create a tenant-scoped pool.
521
+ // Sets app.current_tenant on each connection for RLS enforcement.
522
+ export function getTenantPool(tenantId: string) {
523
+ return createTenantPool(pool, tenantId);
524
+ }
525
+
526
+ // Usage:
527
+ // const tenantPool = getTenantPool("tenant-abc");
528
+ // const { rows } = await tenantPool.query("SELECT * FROM orders");
529
+
530
+ export { pool };
531
+ `
532
+ }
533
+ ];
534
+ }
535
+
536
+ // src/generators/middleware.ts
537
+ function generateMiddleware(projectName, preset) {
538
+ switch (preset.framework) {
539
+ case "express":
540
+ return generateExpressMiddleware(projectName);
541
+ case "fastify":
542
+ return generateFastifyMiddleware(projectName);
543
+ case "nextjs":
544
+ return generateNextjsMiddleware(projectName);
545
+ case "hono":
546
+ return generateHonoMiddleware(projectName);
547
+ case "nestjs":
548
+ return generateNestjsMiddleware(projectName);
549
+ case "none":
550
+ return generateNoFramework(projectName);
551
+ }
552
+ }
553
+ function generateExpressMiddleware(projectName) {
554
+ return [
555
+ {
556
+ filename: "src/index.ts",
557
+ content: `import express from "express";
558
+
559
+ const app = express();
560
+ const port = Number(process.env.PORT) || 3000;
561
+
562
+ app.use(express.json());
563
+
564
+ // Tenant extraction middleware
565
+ app.use((req, _res, next) => {
566
+ const tenantId =
567
+ req.headers["x-tenant-id"] as string ||
568
+ req.hostname.split(".")[0];
569
+ (req as any).tenantId = tenantId;
570
+ next();
571
+ });
572
+
573
+ app.get("/health", (_req, res) => {
574
+ res.json({ status: "ok", project: "${projectName}" });
575
+ });
576
+
577
+ app.get("/tenants", async (req, res) => {
578
+ const tenantId = (req as any).tenantId;
579
+ res.json({ tenantId, message: "Replace with your tenant queries" });
580
+ });
581
+
582
+ app.listen(port, () => {
583
+ console.log(\`${projectName} running on http://localhost:\${port}\`);
584
+ });
585
+ `
586
+ }
587
+ ];
588
+ }
589
+ function generateFastifyMiddleware(projectName) {
590
+ return [
591
+ {
592
+ filename: "src/index.ts",
593
+ content: `import Fastify from "fastify";
594
+
595
+ const fastify = Fastify({ logger: true });
596
+ const port = Number(process.env.PORT) || 3000;
597
+
598
+ // Tenant extraction plugin
599
+ fastify.decorateRequest("tenantId", "");
600
+ fastify.addHook("onRequest", async (request) => {
601
+ const tenantId =
602
+ (request.headers["x-tenant-id"] as string) ||
603
+ (request.hostname?.split(".")[0] ?? "");
604
+ (request as any).tenantId = tenantId;
605
+ });
606
+
607
+ fastify.get("/health", async () => {
608
+ return { status: "ok", project: "${projectName}" };
609
+ });
610
+
611
+ fastify.get("/tenants", async (request) => {
612
+ const tenantId = (request as any).tenantId;
613
+ return { tenantId, message: "Replace with your tenant queries" };
614
+ });
615
+
616
+ fastify.listen({ port, host: "0.0.0.0" }, (err) => {
617
+ if (err) {
618
+ fastify.log.error(err);
619
+ process.exit(1);
620
+ }
621
+ });
622
+ `
623
+ }
624
+ ];
625
+ }
626
+ function generateNextjsMiddleware(projectName) {
627
+ return [
628
+ {
629
+ filename: "middleware.ts",
630
+ content: `// Next.js edge middleware for tenant resolution
631
+ import { NextRequest, NextResponse } from "next/server";
632
+
633
+ export function middleware(request: NextRequest) {
634
+ const hostname = request.headers.get("host") || "";
635
+ const subdomain = hostname.split(".")[0];
636
+ const headerTenantId = request.headers.get("x-tenant-id");
637
+ const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
638
+
639
+ const tenantId = headerTenantId || pathTenantId || subdomain;
640
+
641
+ const requestHeaders = new Headers(request.headers);
642
+ if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
643
+ requestHeaders.set("x-tenant-id", tenantId);
644
+ }
645
+
646
+ return NextResponse.next({ request: { headers: requestHeaders } });
647
+ }
648
+
649
+ export const config = {
650
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
651
+ };
652
+ `
653
+ },
654
+ {
655
+ filename: "src/app/page.tsx",
656
+ content: `// app/page.tsx - ${projectName} root page
657
+ export default function Home() {
658
+ return (
659
+ <main style={{ padding: "2rem", fontFamily: "sans-serif" }}>
660
+ <h1>${projectName}</h1>
661
+ <p>Multi-tenant app powered by Stratum.</p>
662
+ <ul>
663
+ <li>Configure tenants via the Stratum control plane</li>
664
+ <li>Access tenant context via <code>x-tenant-id</code> header</li>
665
+ <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
666
+ </ul>
667
+ </main>
668
+ );
669
+ }
670
+ `
671
+ }
672
+ ];
673
+ }
674
+ function generateHonoMiddleware(projectName) {
675
+ return [
676
+ {
677
+ filename: "src/index.ts",
678
+ content: `import { Hono } from "hono";
679
+ import { serve } from "@hono/node-server";
680
+
681
+ const app = new Hono();
682
+
683
+ // Tenant extraction middleware
684
+ app.use("*", async (c, next) => {
685
+ const tenantId =
686
+ c.req.header("x-tenant-id") ||
687
+ new URL(c.req.url).hostname.split(".")[0];
688
+ c.set("tenantId", tenantId);
689
+ await next();
690
+ });
691
+
692
+ app.get("/health", (c) => {
693
+ return c.json({ status: "ok", project: "${projectName}" });
694
+ });
695
+
696
+ app.get("/tenants", (c) => {
697
+ const tenantId = c.get("tenantId");
698
+ return c.json({ tenantId, message: "Replace with your tenant queries" });
699
+ });
700
+
701
+ const port = Number(process.env.PORT) || 3000;
702
+ console.log(\`${projectName} running on http://localhost:\${port}\`);
703
+ serve({ fetch: app.fetch, port });
704
+ `
705
+ }
706
+ ];
707
+ }
708
+ function generateNestjsMiddleware(projectName) {
709
+ return [
710
+ {
711
+ filename: "src/main.ts",
712
+ content: `import { NestFactory } from "@nestjs/core";
713
+ import { AppModule } from "./app.module.js";
714
+
715
+ async function bootstrap() {
716
+ const app = await NestFactory.create(AppModule);
717
+ const port = Number(process.env.PORT) || 3000;
718
+ await app.listen(port);
719
+ console.log(\`${projectName} running on http://localhost:\${port}\`);
720
+ }
721
+ bootstrap();
722
+ `
723
+ },
724
+ {
725
+ filename: "src/app.module.ts",
726
+ content: `import { Module } from "@nestjs/common";
727
+ import { AppController } from "./app.controller.js";
728
+ import { TenantGuard } from "./tenant.guard.js";
729
+ import { APP_GUARD } from "@nestjs/core";
730
+
731
+ @Module({
732
+ controllers: [AppController],
733
+ providers: [
734
+ { provide: APP_GUARD, useClass: TenantGuard },
735
+ ],
736
+ })
737
+ export class AppModule {}
738
+ `
739
+ },
740
+ {
741
+ filename: "src/app.controller.ts",
742
+ content: `import { Controller, Get, Req } from "@nestjs/common";
743
+
744
+ @Controller()
745
+ export class AppController {
746
+ @Get("health")
747
+ health() {
748
+ return { status: "ok", project: "${projectName}" };
749
+ }
750
+
751
+ @Get("tenants")
752
+ tenants(@Req() req: any) {
753
+ return { tenantId: req.tenantId, message: "Replace with your tenant queries" };
754
+ }
755
+ }
756
+ `
757
+ },
758
+ {
759
+ filename: "src/tenant.guard.ts",
760
+ content: `import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
761
+
762
+ @Injectable()
763
+ export class TenantGuard implements CanActivate {
764
+ canActivate(context: ExecutionContext): boolean {
765
+ const request = context.switchToHttp().getRequest();
766
+ const tenantId =
767
+ request.headers["x-tenant-id"] ||
768
+ request.hostname?.split(".")[0];
769
+ request.tenantId = tenantId || null;
770
+ return true;
771
+ }
772
+ }
773
+ `
774
+ }
775
+ ];
776
+ }
777
+ function generateNoFramework(projectName) {
778
+ return [
779
+ {
780
+ filename: "src/index.ts",
781
+ content: `// ${projectName} - Stratum multi-tenant setup (no framework)
782
+ // Use this as a starting point and integrate with your framework of choice.
783
+
784
+ console.log("${projectName} initialized");
785
+ console.log("Import your database setup from ./stratum-db or equivalent");
786
+ console.log("Add tenant resolution logic for your use case");
787
+ `
788
+ }
789
+ ];
790
+ }
791
+
792
+ // src/generators/package-json.ts
793
+ function generatePresetPackageJson(projectName, preset) {
794
+ const deps = {
795
+ "@stratum-hq/lib": "^0.2.0"
796
+ };
797
+ const devDeps = {
798
+ typescript: "^5.3.0",
799
+ "@types/node": "^20.11.0"
800
+ };
801
+ addDatabaseDeps(deps, preset);
802
+ addOrmDeps(deps, devDeps, preset);
803
+ addFrameworkDeps(deps, devDeps, preset);
804
+ addStratumDeps(deps, preset);
805
+ const scripts = getScripts(preset);
806
+ return JSON.stringify(
807
+ {
808
+ name: projectName,
809
+ version: "0.1.0",
810
+ private: true,
811
+ type: "module",
812
+ scripts,
813
+ dependencies: sortKeys(deps),
814
+ devDependencies: sortKeys(devDeps),
815
+ engines: {
816
+ node: ">=20.0.0"
817
+ }
818
+ },
819
+ null,
820
+ 2
821
+ );
822
+ }
823
+ function addDatabaseDeps(deps, preset) {
824
+ switch (preset.database) {
825
+ case "postgres":
826
+ if (preset.orm !== "prisma" && preset.orm !== "drizzle" && preset.orm !== "sequelize") {
827
+ deps["pg"] = "^8.11.0";
828
+ }
829
+ if (preset.orm === "drizzle") {
830
+ deps["pg"] = "^8.11.0";
831
+ }
832
+ if (preset.orm === "pg") {
833
+ deps["pg"] = "^8.11.0";
834
+ }
835
+ if (preset.orm === "knex") {
836
+ deps["pg"] = "^8.11.0";
837
+ }
838
+ break;
839
+ case "mongodb":
840
+ break;
841
+ case "mysql":
842
+ if (preset.orm === "drizzle" || preset.orm === "pg") {
843
+ deps["mysql2"] = "^3.9.0";
844
+ }
845
+ break;
846
+ }
847
+ }
848
+ function addOrmDeps(deps, devDeps, preset) {
849
+ switch (preset.orm) {
850
+ case "prisma":
851
+ deps["@prisma/client"] = "^5.10.0";
852
+ devDeps["prisma"] = "^5.10.0";
853
+ if (preset.database === "postgres") {
854
+ deps["pg"] = "^8.11.0";
855
+ }
856
+ break;
857
+ case "drizzle":
858
+ deps["drizzle-orm"] = "^0.30.0";
859
+ devDeps["drizzle-kit"] = "^0.21.0";
860
+ if (preset.database === "postgres") {
861
+ deps["pg"] = "^8.11.0";
862
+ }
863
+ break;
864
+ case "sequelize":
865
+ deps["sequelize"] = "^6.37.0";
866
+ if (preset.database === "mysql") {
867
+ deps["mysql2"] = "^3.9.0";
868
+ } else {
869
+ deps["pg"] = "^8.11.0";
870
+ }
871
+ break;
872
+ case "knex":
873
+ deps["knex"] = "^3.1.0";
874
+ break;
875
+ case "mongoose":
876
+ deps["mongoose"] = "^8.2.0";
877
+ break;
878
+ case "pg":
879
+ break;
880
+ }
881
+ }
882
+ function addFrameworkDeps(deps, devDeps, preset) {
883
+ switch (preset.framework) {
884
+ case "express":
885
+ deps["express"] = "^4.18.0";
886
+ devDeps["@types/express"] = "^4.17.21";
887
+ break;
888
+ case "fastify":
889
+ deps["fastify"] = "^4.26.0";
890
+ break;
891
+ case "nextjs":
892
+ deps["next"] = "^15.5.16";
893
+ deps["react"] = "^19.0.0";
894
+ deps["react-dom"] = "^19.0.0";
895
+ devDeps["@types/react"] = "^19.0.0";
896
+ devDeps["@types/react-dom"] = "^19.0.0";
897
+ break;
898
+ case "hono":
899
+ deps["hono"] = "^4.1.0";
900
+ deps["@hono/node-server"] = "^1.8.0";
901
+ break;
902
+ case "nestjs":
903
+ deps["@nestjs/core"] = "^11.0.0";
904
+ deps["@nestjs/common"] = "^11.0.0";
905
+ deps["@nestjs/platform-express"] = "^11.0.0";
906
+ deps["reflect-metadata"] = "^0.2.0";
907
+ deps["rxjs"] = "^7.8.0";
908
+ break;
909
+ case "none":
910
+ break;
911
+ }
912
+ }
913
+ function addStratumDeps(deps, preset) {
914
+ if (preset.database === "postgres" && preset.orm !== "mongoose") {
915
+ deps["@stratum-hq/db-adapters"] = "^0.2.0";
916
+ }
917
+ if (preset.database === "mongodb") {
918
+ deps["@stratum-hq/mongodb"] = "^0.2.0";
919
+ }
920
+ if (preset.database === "mysql") {
921
+ deps["@stratum-hq/mysql"] = "^0.2.0";
922
+ }
923
+ if (preset.framework === "hono") {
924
+ deps["@stratum-hq/hono"] = "^0.2.0";
925
+ }
926
+ if (preset.framework === "nestjs") {
927
+ deps["@stratum-hq/nestjs"] = "^0.2.0";
928
+ }
929
+ }
930
+ function getScripts(preset) {
931
+ if (preset.framework === "nextjs") {
932
+ return { dev: "next dev", build: "next build", start: "next start" };
933
+ }
934
+ if (preset.framework === "nestjs") {
935
+ return { dev: "node --watch src/main.ts", build: "tsc", start: "node dist/main.js" };
936
+ }
937
+ return { dev: "node --watch src/index.js", build: "tsc", start: "node dist/index.js" };
938
+ }
939
+ function sortKeys(obj) {
940
+ const sorted = {};
941
+ for (const key of Object.keys(obj).sort()) {
942
+ sorted[key] = obj[key];
943
+ }
944
+ return sorted;
945
+ }
946
+
947
+ // src/generators/readme.ts
948
+ function generatePresetReadme(projectName, preset) {
949
+ const devCmd = getDevCommand(preset);
950
+ const dbStartCmd = getDbStartInfo(preset);
951
+ const dbSetupNote = getDbSetupNote(preset);
952
+ return `# ${projectName}
953
+
954
+ A multi-tenant application built with [Stratum](https://github.com/stratum-hq/Stratum).
955
+
956
+ **Stack:** ${preset.database} + ${preset.strategy} strategy + ${preset.orm} + ${preset.framework === "none" ? "no framework" : preset.framework}
957
+
958
+ ## Getting started
959
+
960
+ ### 1. Start the database
961
+
962
+ \`\`\`bash
963
+ docker compose up -d
964
+ \`\`\`
965
+ ${dbStartCmd}
966
+
967
+ ### 2. Configure environment
968
+
969
+ \`\`\`bash
970
+ cp .env.example .env
971
+ # Edit .env with your database credentials and Stratum API key
972
+ \`\`\`
973
+
974
+ ### 3. Install dependencies
975
+
976
+ \`\`\`bash
977
+ npm install
978
+ \`\`\`
979
+ ${dbSetupNote}
980
+
981
+ ### 4. Run the app
982
+
983
+ \`\`\`bash
984
+ ${devCmd}
985
+ \`\`\`
986
+
987
+ ## Multi-tenancy
988
+
989
+ This project uses Stratum for hierarchical multi-tenancy with the **${preset.strategy}** isolation strategy:
990
+
991
+ ${getStrategyDescription(preset.strategy)}
992
+
993
+ See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference.
994
+ `;
995
+ }
996
+ function getDevCommand(preset) {
997
+ if (preset.framework === "nextjs")
998
+ return "npm run dev";
999
+ if (preset.framework === "nestjs")
1000
+ return "node --env-file=.env src/main.ts";
1001
+ if (preset.framework === "none")
1002
+ return "node --env-file=.env src/index.ts";
1003
+ return "node --env-file=.env src/index.ts";
1004
+ }
1005
+ function getDbStartInfo(preset) {
1006
+ switch (preset.database) {
1007
+ case "postgres":
1008
+ return "\nThis starts PostgreSQL 16 on port 5432.\n";
1009
+ case "mongodb":
1010
+ return "\nThis starts MongoDB 7 on port 27017.\n";
1011
+ case "mysql":
1012
+ return "\nThis starts MySQL 8 on port 3306.\n";
1013
+ }
1014
+ }
1015
+ function getDbSetupNote(preset) {
1016
+ if (preset.orm === "prisma") {
1017
+ return `
1018
+ ### 3b. Generate Prisma client
1019
+
1020
+ \`\`\`bash
1021
+ npx prisma generate
1022
+ npx prisma db push
1023
+ \`\`\`
1024
+ `;
1025
+ }
1026
+ if (preset.orm === "drizzle") {
1027
+ return `
1028
+ ### 3b. Run Drizzle migrations
1029
+
1030
+ \`\`\`bash
1031
+ npx drizzle-kit push
1032
+ \`\`\`
1033
+ `;
1034
+ }
1035
+ return "";
1036
+ }
1037
+ function getStrategyDescription(strategy) {
1038
+ switch (strategy) {
1039
+ case "rls":
1040
+ return `- **Row-Level Security** -- PostgreSQL RLS policies filter rows by tenant automatically
1041
+ - Each query sets \`app.current_tenant\` and RLS enforces isolation
1042
+ - All tenants share one database and schema`;
1043
+ case "schema":
1044
+ return `- **Schema-per-tenant** -- each tenant gets a dedicated PostgreSQL schema
1045
+ - Queries are routed to the correct schema via search_path
1046
+ - Shared database, isolated schemas`;
1047
+ case "database":
1048
+ return `- **Database-per-tenant** -- each tenant gets a fully isolated database
1049
+ - Connection routing directs queries to the correct database
1050
+ - Maximum isolation at the cost of more resource usage`;
1051
+ case "collection":
1052
+ return `- **Collection-per-tenant** -- each tenant gets dedicated MongoDB collections
1053
+ - Collection names are prefixed or namespaced by tenant ID
1054
+ - Shared database, isolated collections`;
1055
+ case "table-prefix":
1056
+ return `- **Table-prefix** -- tenant-specific tables with a naming prefix
1057
+ - Tables are prefixed with the tenant identifier
1058
+ - Shared database, prefixed table names`;
1059
+ default:
1060
+ return "";
1061
+ }
1062
+ }
1063
+
1064
+ // src/preset-project.ts
1065
+ function writeFile(filePath, content) {
1066
+ const dir = path.dirname(filePath);
1067
+ if (!fs.existsSync(dir)) {
1068
+ fs.mkdirSync(dir, { recursive: true });
1069
+ }
1070
+ fs.writeFileSync(filePath, content, "utf8");
1071
+ console.log(` created ${path.relative(process.cwd(), filePath)}`);
1072
+ }
1073
+ function generatePresetEnv(projectName, preset) {
1074
+ const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
1075
+ const jwtSecret = crypto.randomBytes(32).toString("base64url");
1076
+ let dbUrl;
1077
+ switch (preset.database) {
1078
+ case "postgres":
1079
+ dbUrl = `postgres://${dbName}:dev_password@localhost:5432/${dbName}`;
1080
+ break;
1081
+ case "mongodb":
1082
+ dbUrl = `mongodb://${dbName}:dev_password@localhost:27017/${dbName}?authSource=admin`;
1083
+ break;
1084
+ case "mysql":
1085
+ dbUrl = `mysql://${dbName}:dev_password@localhost:3306/${dbName}`;
1086
+ break;
1087
+ }
1088
+ const urlKey = preset.database === "mongodb" ? "MONGODB_URI" : "DATABASE_URL";
1089
+ return `# Environment variables for ${projectName}
1090
+ # Copy to .env and fill in values
1091
+
1092
+ # Database
1093
+ ${urlKey}=${dbUrl}
1094
+
1095
+ # Authentication
1096
+ JWT_SECRET=${jwtSecret}
1097
+
1098
+ # Stratum Control Plane
1099
+ STRATUM_URL=http://localhost:3001
1100
+ STRATUM_API_KEY=sk_test_your_key_here
1101
+
1102
+ NODE_ENV=development
1103
+ `;
1104
+ }
1105
+ function createPresetProject(projectName, preset, targetDir, skipInstall) {
1106
+ const presetLabel = `${preset.database}/${preset.strategy}/${preset.orm}/${preset.framework}`;
1107
+ console.log(`
1108
+ Creating ${projectName} with preset ${presetLabel}...
1109
+ `);
1110
+ writeFile(
1111
+ path.join(targetDir, "package.json"),
1112
+ generatePresetPackageJson(projectName, preset)
1113
+ );
1114
+ writeFile(
1115
+ path.join(targetDir, "docker-compose.yml"),
1116
+ generatePresetDockerCompose(projectName, preset)
1117
+ );
1118
+ const initSql = generatePresetInitSql(projectName, preset);
1119
+ if (initSql !== null) {
1120
+ writeFile(path.join(targetDir, "init.sql"), initSql);
1121
+ }
1122
+ writeFile(
1123
+ path.join(targetDir, ".env.example"),
1124
+ generatePresetEnv(projectName, preset)
1125
+ );
1126
+ const dbFiles = generateDbSetup(preset);
1127
+ for (const file of dbFiles) {
1128
+ writeFile(path.join(targetDir, file.filename), file.content);
1129
+ }
1130
+ const middlewareFiles = generateMiddleware(projectName, preset);
1131
+ for (const file of middlewareFiles) {
1132
+ writeFile(path.join(targetDir, file.filename), file.content);
1133
+ }
1134
+ writeFile(
1135
+ path.join(targetDir, "README.md"),
1136
+ generatePresetReadme(projectName, preset)
1137
+ );
1138
+ writeFile(
1139
+ path.join(targetDir, "tsconfig.json"),
1140
+ JSON.stringify(
1141
+ {
1142
+ compilerOptions: {
1143
+ target: "ESNext",
1144
+ module: "NodeNext",
1145
+ moduleResolution: "NodeNext",
1146
+ strict: true,
1147
+ outDir: "dist",
1148
+ rootDir: "src",
1149
+ declaration: true,
1150
+ skipLibCheck: true,
1151
+ esModuleInterop: true,
1152
+ ...preset.framework === "nestjs" ? { experimentalDecorators: true, emitDecoratorMetadata: true } : {},
1153
+ ...preset.framework === "nextjs" ? { jsx: "preserve", plugins: [{ name: "next" }] } : {}
1154
+ },
1155
+ include: ["src"],
1156
+ exclude: ["node_modules", "dist"]
1157
+ },
1158
+ null,
1159
+ 2
1160
+ )
1161
+ );
1162
+ if (!skipInstall) {
1163
+ console.log("\nInstalling dependencies...\n");
1164
+ try {
1165
+ execSync("npm install", { cwd: targetDir, stdio: "inherit" });
1166
+ } catch {
1167
+ console.warn("\nWarning: npm install failed. Run it manually in the project directory.");
1168
+ }
1169
+ }
1170
+ }
1171
+
1172
+ // src/index.ts
1173
+ function parseArgs(argv) {
1174
+ const args = [...argv];
1175
+ let projectName = null;
1176
+ let template = "express";
1177
+ let preset = null;
1178
+ let skipInstall = false;
1179
+ let force = false;
1180
+ let templateExplicit = false;
1181
+ for (let i = 0; i < args.length; i++) {
1182
+ const arg = args[i];
1183
+ if (arg === "--skip-install") {
1184
+ skipInstall = true;
1185
+ } else if (arg === "--force") {
1186
+ force = true;
1187
+ } else if (arg === "--template") {
1188
+ const val = args[i + 1];
1189
+ if (val === "express" || val === "fastify" || val === "nextjs") {
1190
+ template = val;
1191
+ templateExplicit = true;
1192
+ i++;
1193
+ } else {
1194
+ console.error(`Unknown template: ${val}. Available: express, fastify, nextjs`);
1195
+ process.exit(1);
1196
+ }
1197
+ } else if (arg.startsWith("--template=")) {
1198
+ const val = arg.slice("--template=".length);
1199
+ if (val === "express" || val === "fastify" || val === "nextjs") {
1200
+ template = val;
1201
+ templateExplicit = true;
1202
+ } else {
1203
+ console.error(`Unknown template: ${val}. Available: express, fastify, nextjs`);
1204
+ process.exit(1);
1205
+ }
1206
+ } else if (arg === "--preset") {
1207
+ preset = args[i + 1] || null;
1208
+ i++;
1209
+ } else if (arg.startsWith("--preset=")) {
1210
+ preset = arg.slice("--preset=".length) || null;
1211
+ } else if (!arg.startsWith("--")) {
1212
+ projectName = arg;
1213
+ }
1214
+ }
1215
+ if (preset && templateExplicit) {
1216
+ console.error("Error: --template and --preset cannot be used together. Use one or the other.");
1217
+ process.exit(1);
1218
+ }
1219
+ return { projectName, template, preset, skipInstall, force };
1220
+ }
1221
+ function printUsage() {
1222
+ console.log("Usage: create-stratum <project-name> [options]");
1223
+ console.log("");
1224
+ console.log("Options:");
1225
+ console.log(" --template <express|fastify|nextjs> Framework template (default: express)");
1226
+ console.log(" --preset <db-strategy-orm-framework> Full stack preset (e.g. postgres-rls-prisma-express)");
1227
+ console.log(" --skip-install Skip npm install");
1228
+ console.log(" --force Overwrite existing directory");
1229
+ console.log("");
1230
+ console.log("Examples:");
1231
+ console.log(" npx @stratum-hq/create my-app");
1232
+ console.log(" npx @stratum-hq/create my-app --template fastify");
1233
+ console.log(" npx @stratum-hq/create my-app --preset postgres-rls-prisma-express");
1234
+ console.log(" npx @stratum-hq/create my-app --preset mongodb-database-mongoose-hono");
1235
+ console.log(" npx @stratum-hq/create my-app --preset mysql-table-prefix-sequelize-nestjs");
1236
+ }
1237
+ function writeFile2(filePath, content) {
1238
+ const dir = path2.dirname(filePath);
1239
+ if (!fs2.existsSync(dir)) {
1240
+ fs2.mkdirSync(dir, { recursive: true });
1241
+ }
1242
+ fs2.writeFileSync(filePath, content, "utf8");
1243
+ console.log(` created ${path2.relative(process.cwd(), filePath)}`);
1244
+ }
1245
+ function generatePackageJson(projectName, template) {
1246
+ const frameworkDeps = {
1247
+ express: {
1248
+ express: "^4.18.0",
1249
+ "@types/express": "^4.17.21"
1250
+ },
1251
+ fastify: {
1252
+ fastify: "^4.26.0"
1253
+ },
1254
+ nextjs: {
1255
+ next: "^15.5.16",
1256
+ react: "^19.0.0",
1257
+ "react-dom": "^19.0.0",
1258
+ "@types/react": "^19.0.0",
1259
+ "@types/react-dom": "^19.0.0"
1260
+ }
1261
+ };
1262
+ const deps = {
1263
+ "@stratum-hq/lib": "^0.2.0",
1264
+ pg: "^8.11.0",
1265
+ ...frameworkDeps[template]
1266
+ };
1267
+ return JSON.stringify(
1268
+ {
1269
+ name: projectName,
1270
+ version: "0.1.0",
1271
+ private: true,
1272
+ type: "module",
1273
+ scripts: template === "nextjs" ? { dev: "next dev", build: "next build", start: "next start" } : { dev: "node --watch src/index.js", build: "tsc", start: "node dist/index.js" },
1274
+ dependencies: deps,
1275
+ devDependencies: {
1276
+ typescript: "^5.3.0",
1277
+ "@types/node": "^20.11.0"
1278
+ },
1279
+ engines: {
1280
+ node: ">=20.0.0"
1281
+ }
1282
+ },
1283
+ null,
1284
+ 2
1285
+ );
1286
+ }
1287
+ function generateDockerCompose(projectName) {
1288
+ const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
1289
+ return `# Docker Compose for ${projectName}
1290
+ # Start with: docker compose up -d
1291
+ version: "3.8"
1292
+
1293
+ services:
1294
+ db:
1295
+ image: postgres:16-alpine
1296
+ environment:
1297
+ POSTGRES_DB: ${dbName}
1298
+ POSTGRES_USER: ${dbName}
1299
+ POSTGRES_PASSWORD: dev_password
1300
+ ports:
1301
+ - "5432:5432"
1302
+ volumes:
1303
+ - db_data:/var/lib/postgresql/data
1304
+ - ./init.sql:/docker-entrypoint-initdb.d/init.sql
1305
+ healthcheck:
1306
+ test: ["CMD-SHELL", "pg_isready -U ${dbName}"]
1307
+ interval: 5s
1308
+ timeout: 5s
1309
+ retries: 5
1310
+
1311
+ volumes:
1312
+ db_data:
1313
+ `;
1314
+ }
1315
+ function generateInitSql(projectName) {
1316
+ const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
1317
+ return `-- Initialize ${projectName} database
1318
+ -- Enable required extensions for Stratum multi-tenancy
1319
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
1320
+ CREATE EXTENSION IF NOT EXISTS "ltree";
1321
+
1322
+ -- The ltree extension enables hierarchical tenant trees
1323
+ -- uuid-ossp provides uuid_generate_v4() for tenant IDs
1324
+ COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
1325
+ `;
1326
+ }
1327
+ function generateEnv(projectName) {
1328
+ const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
1329
+ const jwtSecret = crypto2.randomBytes(32).toString("base64url");
1330
+ return `# Environment variables for ${projectName}
1331
+ # Copy to .env and fill in values
1332
+
1333
+ # Database
1334
+ DATABASE_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
1335
+
1336
+ # Authentication
1337
+ JWT_SECRET=${jwtSecret}
1338
+
1339
+ # Stratum Control Plane
1340
+ STRATUM_URL=http://localhost:3001
1341
+ STRATUM_API_KEY=sk_test_your_key_here
1342
+
1343
+ NODE_ENV=development
1344
+ `;
1345
+ }
1346
+ function generateExpressServer(projectName) {
1347
+ return `import express from "express";
1348
+ import { Pool } from "pg";
1349
+
1350
+ const app = express();
1351
+ const port = process.env.PORT || 3000;
1352
+
1353
+ const pool = new Pool({
1354
+ connectionString: process.env.DATABASE_URL,
1355
+ });
1356
+
1357
+ app.use(express.json());
1358
+
1359
+ app.get("/health", (_req, res) => {
1360
+ res.json({ status: "ok", project: "${projectName}" });
1361
+ });
1362
+
1363
+ app.get("/tenants", async (_req, res) => {
1364
+ try {
1365
+ // Example: query your tenant table
1366
+ // const { rows } = await pool.query("SELECT * FROM tenants LIMIT 20");
1367
+ // res.json(rows);
1368
+ res.json({ message: "Replace this with your tenant queries", pool: !!pool });
1369
+ } catch (err) {
1370
+ res.status(500).json({ error: String(err) });
1371
+ }
1372
+ });
1373
+
1374
+ app.listen(port, () => {
1375
+ console.log(\`${projectName} server running on http://localhost:\${port}\`);
1376
+ });
1377
+ `;
1378
+ }
1379
+ function generateFastifyServer(projectName) {
1380
+ return `import Fastify from "fastify";
1381
+ import { Pool } from "pg";
1382
+
1383
+ const fastify = Fastify({ logger: true });
1384
+ const port = Number(process.env.PORT) || 3000;
1385
+
1386
+ const pool = new Pool({
1387
+ connectionString: process.env.DATABASE_URL,
1388
+ });
1389
+
1390
+ fastify.get("/health", async () => {
1391
+ return { status: "ok", project: "${projectName}" };
1392
+ });
1393
+
1394
+ fastify.get("/tenants", async (_request, reply) => {
1395
+ try {
1396
+ // Example: query your tenant table
1397
+ // const { rows } = await pool.query("SELECT * FROM tenants LIMIT 20");
1398
+ // return rows;
1399
+ return { message: "Replace this with your tenant queries", pool: !!pool };
1400
+ } catch (err) {
1401
+ return reply.status(500).send({ error: String(err) });
1402
+ }
1403
+ });
1404
+
1405
+ fastify.listen({ port, host: "0.0.0.0" }, (err) => {
1406
+ if (err) {
1407
+ fastify.log.error(err);
1408
+ process.exit(1);
1409
+ }
1410
+ });
1411
+ `;
1412
+ }
1413
+ function generateNextjsPage(projectName) {
1414
+ return `// app/page.tsx \u2014 ${projectName} root page
1415
+ export default function Home() {
1416
+ return (
1417
+ <main style={{ padding: "2rem", fontFamily: "sans-serif" }}>
1418
+ <h1>${projectName}</h1>
1419
+ <p>Multi-tenant app powered by Stratum.</p>
1420
+ <ul>
1421
+ <li>Configure tenants via the Stratum control plane</li>
1422
+ <li>Access tenant context via <code>x-tenant-id</code> header</li>
1423
+ <li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
1424
+ </ul>
1425
+ </main>
1426
+ );
1427
+ }
1428
+ `;
1429
+ }
1430
+ function generateNextjsMiddleware2() {
1431
+ return `// middleware.ts \u2014 tenant resolution via subdomain or header
1432
+ import { NextRequest, NextResponse } from "next/server";
1433
+
1434
+ export function middleware(request: NextRequest) {
1435
+ const hostname = request.headers.get("host") || "";
1436
+ const subdomain = hostname.split(".")[0];
1437
+ const headerTenantId = request.headers.get("x-tenant-id");
1438
+ const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
1439
+
1440
+ const tenantId = headerTenantId || pathTenantId || subdomain;
1441
+
1442
+ const requestHeaders = new Headers(request.headers);
1443
+ if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
1444
+ requestHeaders.set("x-tenant-id", tenantId);
1445
+ }
1446
+
1447
+ return NextResponse.next({ request: { headers: requestHeaders } });
1448
+ }
1449
+
1450
+ export const config = {
1451
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
1452
+ };
1453
+ `;
1454
+ }
1455
+ function generateReadme(projectName, template) {
1456
+ const devCmd = template === "nextjs" ? "npm run dev" : "node --env-file=.env src/index.ts";
1457
+ return `# ${projectName}
1458
+
1459
+ A multi-tenant application built with [Stratum](https://github.com/stratum-hq/Stratum).
1460
+
1461
+ ## Getting started
1462
+
1463
+ ### 1. Start the database
1464
+
1465
+ \`\`\`bash
1466
+ docker compose up -d
1467
+ \`\`\`
1468
+
1469
+ ### 2. Configure environment
1470
+
1471
+ \`\`\`bash
1472
+ cp .env.example .env
1473
+ # Edit .env \u2014 update DATABASE_URL, JWT_SECRET, and STRATUM_API_KEY
1474
+ \`\`\`
1475
+
1476
+ ### 3. Install dependencies
1477
+
1478
+ \`\`\`bash
1479
+ npm install
1480
+ \`\`\`
1481
+
1482
+ ### 4. Run the app
1483
+
1484
+ \`\`\`bash
1485
+ ${devCmd}
1486
+ \`\`\`
1487
+
1488
+ ## Project structure
1489
+
1490
+ \`\`\`
1491
+ ${projectName}/
1492
+ \u251C\u2500\u2500 src/ # Application source
1493
+ \u251C\u2500\u2500 docker-compose.yml
1494
+ \u251C\u2500\u2500 init.sql # DB extensions (uuid-ossp, ltree)
1495
+ \u251C\u2500\u2500 .env.example
1496
+ \u2514\u2500\u2500 package.json
1497
+ \`\`\`
1498
+
1499
+ ## Multi-tenancy
1500
+
1501
+ This project uses Stratum for hierarchical multi-tenancy:
1502
+
1503
+ - **Tenant resolution** \u2014 via JWT claim, subdomain, or \`x-tenant-id\` header
1504
+ - **Config inheritance** \u2014 settings flow down the tenant tree with override support
1505
+ - **Permission ABAC** \u2014 role-based permissions with tenant-scoped enforcement
1506
+
1507
+ See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference.
1508
+ `;
1509
+ }
1510
+ function createProject(projectName, template, targetDir, skipInstall) {
1511
+ console.log(`
1512
+ Creating ${projectName} with ${template} template...
1513
+ `);
1514
+ writeFile2(
1515
+ path2.join(targetDir, "package.json"),
1516
+ generatePackageJson(projectName, template)
1517
+ );
1518
+ writeFile2(path2.join(targetDir, "docker-compose.yml"), generateDockerCompose(projectName));
1519
+ writeFile2(path2.join(targetDir, "init.sql"), generateInitSql(projectName));
1520
+ writeFile2(path2.join(targetDir, ".env.example"), generateEnv(projectName));
1521
+ if (template === "express") {
1522
+ writeFile2(path2.join(targetDir, "src", "index.ts"), generateExpressServer(projectName));
1523
+ } else if (template === "fastify") {
1524
+ writeFile2(path2.join(targetDir, "src", "index.ts"), generateFastifyServer(projectName));
1525
+ } else if (template === "nextjs") {
1526
+ writeFile2(path2.join(targetDir, "src", "app", "page.tsx"), generateNextjsPage(projectName));
1527
+ writeFile2(path2.join(targetDir, "middleware.ts"), generateNextjsMiddleware2());
1528
+ }
1529
+ writeFile2(path2.join(targetDir, "README.md"), generateReadme(projectName, template));
1530
+ if (!skipInstall) {
1531
+ console.log("\nInstalling dependencies...\n");
1532
+ try {
1533
+ execSync2("npm install", { cwd: targetDir, stdio: "inherit" });
1534
+ } catch {
1535
+ console.warn("\nWarning: npm install failed. Run it manually in the project directory.");
1536
+ }
1537
+ }
1538
+ }
1539
+ var VALID_PROJECT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
1540
+ function validateProjectName(projectName) {
1541
+ if (!projectName) {
1542
+ return "Project name must not be empty.";
1543
+ }
1544
+ if (!VALID_PROJECT_NAME.test(projectName)) {
1545
+ return `"${projectName}" is not a valid project name. Use only letters, numbers, dots, hyphens, and underscores. Must start with a letter or number.`;
1546
+ }
1547
+ const targetDir = path2.resolve(process.cwd(), projectName);
1548
+ if (path2.dirname(targetDir) !== process.cwd()) {
1549
+ return "Project name must not contain path separators.";
1550
+ }
1551
+ return null;
1552
+ }
1553
+ function main(argv) {
1554
+ const { projectName, template, preset, skipInstall, force } = parseArgs(argv);
1555
+ if (!projectName) {
1556
+ printUsage();
1557
+ process.exit(1);
1558
+ }
1559
+ const validationError = validateProjectName(projectName);
1560
+ if (validationError) {
1561
+ console.error(`Error: ${validationError}`);
1562
+ process.exit(1);
1563
+ }
1564
+ const targetDir = path2.resolve(process.cwd(), projectName);
1565
+ if (fs2.existsSync(targetDir) && !force) {
1566
+ console.error(`Error: Directory "${projectName}" already exists.`);
1567
+ console.error(`Use --force to overwrite.`);
1568
+ process.exit(1);
1569
+ }
1570
+ if (fs2.existsSync(targetDir) && force) {
1571
+ fs2.rmSync(targetDir, { recursive: true, force: true });
1572
+ }
1573
+ fs2.mkdirSync(targetDir, { recursive: true });
1574
+ if (preset) {
1575
+ const parsed = parsePresetString(preset);
1576
+ if (!parsed) {
1577
+ console.error(`Error: Invalid preset string "${preset}".`);
1578
+ console.error("Format: {database}-{strategy}-{orm}-{framework}");
1579
+ console.error("Example: postgres-rls-prisma-express, mongodb-database-mongoose-hono");
1580
+ process.exit(1);
1581
+ }
1582
+ if (!isValidPreset(parsed)) {
1583
+ console.error(`Error: Invalid preset combination "${preset}".`);
1584
+ console.error(`${parsed.database} does not support ${parsed.strategy}/${parsed.orm} together.`);
1585
+ console.error("Run with --help to see valid combinations.");
1586
+ process.exit(1);
1587
+ }
1588
+ createPresetProject(projectName, parsed, targetDir, skipInstall);
1589
+ } else {
1590
+ createProject(projectName, template, targetDir, skipInstall);
1591
+ }
1592
+ console.log(`
1593
+ Success! Created ${projectName} at ${targetDir}
1594
+ `);
1595
+ console.log("Next steps:\n");
1596
+ console.log(` cd ${projectName}`);
1597
+ console.log(" docker compose up -d");
1598
+ console.log(" cp .env.example .env");
1599
+ if (skipInstall) {
1600
+ console.log(" npm install");
1601
+ }
1602
+ if (preset) {
1603
+ const parsed = parsePresetString(preset);
1604
+ if (parsed.framework === "nextjs") {
1605
+ console.log(" npm run dev");
1606
+ } else if (parsed.framework === "nestjs") {
1607
+ console.log(" node --env-file=.env src/main.ts");
1608
+ } else {
1609
+ console.log(" node --env-file=.env src/index.ts");
1610
+ }
1611
+ } else if (template === "nextjs") {
1612
+ console.log(" npm run dev");
1613
+ } else {
1614
+ console.log(" node --env-file=.env src/index.ts");
1615
+ }
1616
+ console.log("");
1617
+ }
1618
+ var __filename = fileURLToPath(import.meta.url);
1619
+ if (process.argv[1] === __filename) {
1620
+ main(process.argv.slice(2));
1621
+ }
1622
+ export {
1623
+ createProject,
1624
+ main,
1625
+ parseArgs,
1626
+ validateProjectName
1627
+ };