@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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/index.js +1627 -0
- package/package.json +38 -5
- package/src/__tests__/matrix.test.ts +283 -0
- package/src/__tests__/preset.test.ts +266 -0
- package/src/generators/db-setup.ts +320 -0
- package/src/generators/docker-compose.ts +98 -0
- package/src/generators/init-sql.ts +57 -0
- package/src/generators/middleware.ts +267 -0
- package/src/generators/package-json.ts +174 -0
- package/src/generators/readme.ts +119 -0
- package/src/index.ts +61 -11
- package/src/matrix.ts +186 -0
- package/src/preset-project.ts +148 -0
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
|
+
};
|