@stratum-hq/create 0.2.2 → 0.2.3
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 +419 -0
- package/package.json +18 -5
package/dist/index.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import * as fs from "fs";
|
|
5
|
+
import * as path from "path";
|
|
6
|
+
import { execSync } from "child_process";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const args = [...argv];
|
|
10
|
+
let projectName = null;
|
|
11
|
+
let template = "express";
|
|
12
|
+
let skipInstall = false;
|
|
13
|
+
let force = false;
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const arg = args[i];
|
|
16
|
+
if (arg === "--skip-install") {
|
|
17
|
+
skipInstall = true;
|
|
18
|
+
} else if (arg === "--force") {
|
|
19
|
+
force = true;
|
|
20
|
+
} else if (arg === "--template") {
|
|
21
|
+
const val = args[i + 1];
|
|
22
|
+
if (val === "express" || val === "fastify" || val === "nextjs") {
|
|
23
|
+
template = val;
|
|
24
|
+
i++;
|
|
25
|
+
} else {
|
|
26
|
+
console.error(`Unknown template: ${val}. Available: express, fastify, nextjs`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
} else if (arg.startsWith("--template=")) {
|
|
30
|
+
const val = arg.slice("--template=".length);
|
|
31
|
+
if (val === "express" || val === "fastify" || val === "nextjs") {
|
|
32
|
+
template = val;
|
|
33
|
+
} else {
|
|
34
|
+
console.error(`Unknown template: ${val}. Available: express, fastify, nextjs`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
} else if (!arg.startsWith("--")) {
|
|
38
|
+
projectName = arg;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { projectName, template, skipInstall, force };
|
|
42
|
+
}
|
|
43
|
+
function printUsage() {
|
|
44
|
+
console.log("Usage: create-stratum <project-name> [options]");
|
|
45
|
+
console.log("");
|
|
46
|
+
console.log("Options:");
|
|
47
|
+
console.log(" --template <express|fastify|nextjs> Framework template (default: express)");
|
|
48
|
+
console.log(" --skip-install Skip npm install");
|
|
49
|
+
console.log(" --force Overwrite existing directory");
|
|
50
|
+
console.log("");
|
|
51
|
+
console.log("Examples:");
|
|
52
|
+
console.log(" npx @stratum-hq/create my-app");
|
|
53
|
+
console.log(" npx @stratum-hq/create my-app --template fastify");
|
|
54
|
+
console.log(" npx @stratum-hq/create my-app --template nextjs --skip-install");
|
|
55
|
+
}
|
|
56
|
+
function writeFile(filePath, content) {
|
|
57
|
+
const dir = path.dirname(filePath);
|
|
58
|
+
if (!fs.existsSync(dir)) {
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
62
|
+
console.log(` created ${path.relative(process.cwd(), filePath)}`);
|
|
63
|
+
}
|
|
64
|
+
function generatePackageJson(projectName, template) {
|
|
65
|
+
const frameworkDeps = {
|
|
66
|
+
express: {
|
|
67
|
+
express: "^4.18.0",
|
|
68
|
+
"@types/express": "^4.17.21"
|
|
69
|
+
},
|
|
70
|
+
fastify: {
|
|
71
|
+
fastify: "^4.26.0"
|
|
72
|
+
},
|
|
73
|
+
nextjs: {
|
|
74
|
+
next: "^14.1.0",
|
|
75
|
+
react: "^18.2.0",
|
|
76
|
+
"react-dom": "^18.2.0",
|
|
77
|
+
"@types/react": "^18.2.0",
|
|
78
|
+
"@types/react-dom": "^18.2.0"
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const deps = {
|
|
82
|
+
"@stratum-hq/lib": "^0.2.0",
|
|
83
|
+
pg: "^8.11.0",
|
|
84
|
+
...frameworkDeps[template]
|
|
85
|
+
};
|
|
86
|
+
return JSON.stringify(
|
|
87
|
+
{
|
|
88
|
+
name: projectName,
|
|
89
|
+
version: "0.1.0",
|
|
90
|
+
private: true,
|
|
91
|
+
type: "module",
|
|
92
|
+
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" },
|
|
93
|
+
dependencies: deps,
|
|
94
|
+
devDependencies: {
|
|
95
|
+
typescript: "^5.3.0",
|
|
96
|
+
"@types/node": "^20.11.0"
|
|
97
|
+
},
|
|
98
|
+
engines: {
|
|
99
|
+
node: ">=20.0.0"
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
null,
|
|
103
|
+
2
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
function generateDockerCompose(projectName) {
|
|
107
|
+
const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
|
|
108
|
+
return `# Docker Compose for ${projectName}
|
|
109
|
+
# Start with: docker compose up -d
|
|
110
|
+
version: "3.8"
|
|
111
|
+
|
|
112
|
+
services:
|
|
113
|
+
db:
|
|
114
|
+
image: postgres:16-alpine
|
|
115
|
+
environment:
|
|
116
|
+
POSTGRES_DB: ${dbName}
|
|
117
|
+
POSTGRES_USER: ${dbName}
|
|
118
|
+
POSTGRES_PASSWORD: dev_password
|
|
119
|
+
ports:
|
|
120
|
+
- "5432:5432"
|
|
121
|
+
volumes:
|
|
122
|
+
- db_data:/var/lib/postgresql/data
|
|
123
|
+
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
|
|
124
|
+
healthcheck:
|
|
125
|
+
test: ["CMD-SHELL", "pg_isready -U ${dbName}"]
|
|
126
|
+
interval: 5s
|
|
127
|
+
timeout: 5s
|
|
128
|
+
retries: 5
|
|
129
|
+
|
|
130
|
+
volumes:
|
|
131
|
+
db_data:
|
|
132
|
+
`;
|
|
133
|
+
}
|
|
134
|
+
function generateInitSql(projectName) {
|
|
135
|
+
const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
|
|
136
|
+
return `-- Initialize ${projectName} database
|
|
137
|
+
-- Enable required extensions for Stratum multi-tenancy
|
|
138
|
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
139
|
+
CREATE EXTENSION IF NOT EXISTS "ltree";
|
|
140
|
+
|
|
141
|
+
-- The ltree extension enables hierarchical tenant trees
|
|
142
|
+
-- uuid-ossp provides uuid_generate_v4() for tenant IDs
|
|
143
|
+
COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
|
|
144
|
+
`;
|
|
145
|
+
}
|
|
146
|
+
function generateEnv(projectName) {
|
|
147
|
+
const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
|
|
148
|
+
return `# Environment variables for ${projectName}
|
|
149
|
+
# Copy to .env and fill in values
|
|
150
|
+
|
|
151
|
+
# Database
|
|
152
|
+
DATABASE_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
|
|
153
|
+
|
|
154
|
+
# Authentication
|
|
155
|
+
JWT_SECRET=change-me-in-production
|
|
156
|
+
|
|
157
|
+
# Stratum Control Plane
|
|
158
|
+
STRATUM_URL=http://localhost:3001
|
|
159
|
+
STRATUM_API_KEY=sk_test_your_key_here
|
|
160
|
+
|
|
161
|
+
NODE_ENV=development
|
|
162
|
+
`;
|
|
163
|
+
}
|
|
164
|
+
function generateExpressServer(projectName) {
|
|
165
|
+
return `import express from "express";
|
|
166
|
+
import { Pool } from "pg";
|
|
167
|
+
|
|
168
|
+
const app = express();
|
|
169
|
+
const port = process.env.PORT || 3000;
|
|
170
|
+
|
|
171
|
+
const pool = new Pool({
|
|
172
|
+
connectionString: process.env.DATABASE_URL,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
app.use(express.json());
|
|
176
|
+
|
|
177
|
+
app.get("/health", (_req, res) => {
|
|
178
|
+
res.json({ status: "ok", project: "${projectName}" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
app.get("/tenants", async (_req, res) => {
|
|
182
|
+
try {
|
|
183
|
+
// Example: query your tenant table
|
|
184
|
+
// const { rows } = await pool.query("SELECT * FROM tenants LIMIT 20");
|
|
185
|
+
// res.json(rows);
|
|
186
|
+
res.json({ message: "Replace this with your tenant queries", pool: !!pool });
|
|
187
|
+
} catch (err) {
|
|
188
|
+
res.status(500).json({ error: String(err) });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
app.listen(port, () => {
|
|
193
|
+
console.log(\`${projectName} server running on http://localhost:\${port}\`);
|
|
194
|
+
});
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
function generateFastifyServer(projectName) {
|
|
198
|
+
return `import Fastify from "fastify";
|
|
199
|
+
import { Pool } from "pg";
|
|
200
|
+
|
|
201
|
+
const fastify = Fastify({ logger: true });
|
|
202
|
+
const port = Number(process.env.PORT) || 3000;
|
|
203
|
+
|
|
204
|
+
const pool = new Pool({
|
|
205
|
+
connectionString: process.env.DATABASE_URL,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
fastify.get("/health", async () => {
|
|
209
|
+
return { status: "ok", project: "${projectName}" };
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
fastify.get("/tenants", async (_request, reply) => {
|
|
213
|
+
try {
|
|
214
|
+
// Example: query your tenant table
|
|
215
|
+
// const { rows } = await pool.query("SELECT * FROM tenants LIMIT 20");
|
|
216
|
+
// return rows;
|
|
217
|
+
return { message: "Replace this with your tenant queries", pool: !!pool };
|
|
218
|
+
} catch (err) {
|
|
219
|
+
return reply.status(500).send({ error: String(err) });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
fastify.listen({ port, host: "0.0.0.0" }, (err) => {
|
|
224
|
+
if (err) {
|
|
225
|
+
fastify.log.error(err);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
`;
|
|
230
|
+
}
|
|
231
|
+
function generateNextjsPage(projectName) {
|
|
232
|
+
return `// app/page.tsx \u2014 ${projectName} root page
|
|
233
|
+
export default function Home() {
|
|
234
|
+
return (
|
|
235
|
+
<main style={{ padding: "2rem", fontFamily: "sans-serif" }}>
|
|
236
|
+
<h1>${projectName}</h1>
|
|
237
|
+
<p>Multi-tenant app powered by Stratum.</p>
|
|
238
|
+
<ul>
|
|
239
|
+
<li>Configure tenants via the Stratum control plane</li>
|
|
240
|
+
<li>Access tenant context via <code>x-tenant-id</code> header</li>
|
|
241
|
+
<li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
|
|
242
|
+
</ul>
|
|
243
|
+
</main>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
`;
|
|
247
|
+
}
|
|
248
|
+
function generateNextjsMiddleware() {
|
|
249
|
+
return `// middleware.ts \u2014 tenant resolution via subdomain or header
|
|
250
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
251
|
+
|
|
252
|
+
export function middleware(request: NextRequest) {
|
|
253
|
+
const hostname = request.headers.get("host") || "";
|
|
254
|
+
const subdomain = hostname.split(".")[0];
|
|
255
|
+
const headerTenantId = request.headers.get("x-tenant-id");
|
|
256
|
+
const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
|
|
257
|
+
|
|
258
|
+
const tenantId = headerTenantId || pathTenantId || subdomain;
|
|
259
|
+
|
|
260
|
+
const requestHeaders = new Headers(request.headers);
|
|
261
|
+
if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
|
|
262
|
+
requestHeaders.set("x-tenant-id", tenantId);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const config = {
|
|
269
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
270
|
+
};
|
|
271
|
+
`;
|
|
272
|
+
}
|
|
273
|
+
function generateReadme(projectName, template) {
|
|
274
|
+
const devCmd = template === "nextjs" ? "npm run dev" : "node --env-file=.env src/index.ts";
|
|
275
|
+
return `# ${projectName}
|
|
276
|
+
|
|
277
|
+
A multi-tenant application built with [Stratum](https://github.com/stratum-hq/Stratum).
|
|
278
|
+
|
|
279
|
+
## Getting started
|
|
280
|
+
|
|
281
|
+
### 1. Start the database
|
|
282
|
+
|
|
283
|
+
\`\`\`bash
|
|
284
|
+
docker compose up -d
|
|
285
|
+
\`\`\`
|
|
286
|
+
|
|
287
|
+
### 2. Configure environment
|
|
288
|
+
|
|
289
|
+
\`\`\`bash
|
|
290
|
+
cp .env.example .env
|
|
291
|
+
# Edit .env \u2014 update DATABASE_URL, JWT_SECRET, and STRATUM_API_KEY
|
|
292
|
+
\`\`\`
|
|
293
|
+
|
|
294
|
+
### 3. Install dependencies
|
|
295
|
+
|
|
296
|
+
\`\`\`bash
|
|
297
|
+
npm install
|
|
298
|
+
\`\`\`
|
|
299
|
+
|
|
300
|
+
### 4. Run the app
|
|
301
|
+
|
|
302
|
+
\`\`\`bash
|
|
303
|
+
${devCmd}
|
|
304
|
+
\`\`\`
|
|
305
|
+
|
|
306
|
+
## Project structure
|
|
307
|
+
|
|
308
|
+
\`\`\`
|
|
309
|
+
${projectName}/
|
|
310
|
+
\u251C\u2500\u2500 src/ # Application source
|
|
311
|
+
\u251C\u2500\u2500 docker-compose.yml
|
|
312
|
+
\u251C\u2500\u2500 init.sql # DB extensions (uuid-ossp, ltree)
|
|
313
|
+
\u251C\u2500\u2500 .env.example
|
|
314
|
+
\u2514\u2500\u2500 package.json
|
|
315
|
+
\`\`\`
|
|
316
|
+
|
|
317
|
+
## Multi-tenancy
|
|
318
|
+
|
|
319
|
+
This project uses Stratum for hierarchical multi-tenancy:
|
|
320
|
+
|
|
321
|
+
- **Tenant resolution** \u2014 via JWT claim, subdomain, or \`x-tenant-id\` header
|
|
322
|
+
- **Config inheritance** \u2014 settings flow down the tenant tree with override support
|
|
323
|
+
- **Permission ABAC** \u2014 role-based permissions with tenant-scoped enforcement
|
|
324
|
+
|
|
325
|
+
See the [Stratum docs](https://github.com/stratum-hq/Stratum) for full reference.
|
|
326
|
+
`;
|
|
327
|
+
}
|
|
328
|
+
function createProject(projectName, template, targetDir, skipInstall) {
|
|
329
|
+
console.log(`
|
|
330
|
+
Creating ${projectName} with ${template} template...
|
|
331
|
+
`);
|
|
332
|
+
writeFile(
|
|
333
|
+
path.join(targetDir, "package.json"),
|
|
334
|
+
generatePackageJson(projectName, template)
|
|
335
|
+
);
|
|
336
|
+
writeFile(path.join(targetDir, "docker-compose.yml"), generateDockerCompose(projectName));
|
|
337
|
+
writeFile(path.join(targetDir, "init.sql"), generateInitSql(projectName));
|
|
338
|
+
writeFile(path.join(targetDir, ".env.example"), generateEnv(projectName));
|
|
339
|
+
if (template === "express") {
|
|
340
|
+
writeFile(path.join(targetDir, "src", "index.ts"), generateExpressServer(projectName));
|
|
341
|
+
} else if (template === "fastify") {
|
|
342
|
+
writeFile(path.join(targetDir, "src", "index.ts"), generateFastifyServer(projectName));
|
|
343
|
+
} else if (template === "nextjs") {
|
|
344
|
+
writeFile(path.join(targetDir, "src", "app", "page.tsx"), generateNextjsPage(projectName));
|
|
345
|
+
writeFile(path.join(targetDir, "middleware.ts"), generateNextjsMiddleware());
|
|
346
|
+
}
|
|
347
|
+
writeFile(path.join(targetDir, "README.md"), generateReadme(projectName, template));
|
|
348
|
+
if (!skipInstall) {
|
|
349
|
+
console.log("\nInstalling dependencies...\n");
|
|
350
|
+
try {
|
|
351
|
+
execSync("npm install", { cwd: targetDir, stdio: "inherit" });
|
|
352
|
+
} catch {
|
|
353
|
+
console.warn("\nWarning: npm install failed. Run it manually in the project directory.");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
var VALID_PROJECT_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
358
|
+
function validateProjectName(projectName) {
|
|
359
|
+
if (!projectName) {
|
|
360
|
+
return "Project name must not be empty.";
|
|
361
|
+
}
|
|
362
|
+
if (!VALID_PROJECT_NAME.test(projectName)) {
|
|
363
|
+
return `"${projectName}" is not a valid project name. Use only letters, numbers, dots, hyphens, and underscores. Must start with a letter or number.`;
|
|
364
|
+
}
|
|
365
|
+
const targetDir = path.resolve(process.cwd(), projectName);
|
|
366
|
+
if (path.dirname(targetDir) !== process.cwd()) {
|
|
367
|
+
return "Project name must not contain path separators.";
|
|
368
|
+
}
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
function main(argv) {
|
|
372
|
+
const { projectName, template, skipInstall, force } = parseArgs(argv);
|
|
373
|
+
if (!projectName) {
|
|
374
|
+
printUsage();
|
|
375
|
+
process.exit(1);
|
|
376
|
+
}
|
|
377
|
+
const validationError = validateProjectName(projectName);
|
|
378
|
+
if (validationError) {
|
|
379
|
+
console.error(`Error: ${validationError}`);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
const targetDir = path.resolve(process.cwd(), projectName);
|
|
383
|
+
if (fs.existsSync(targetDir) && !force) {
|
|
384
|
+
console.error(`Error: Directory "${projectName}" already exists.`);
|
|
385
|
+
console.error(`Use --force to overwrite.`);
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
if (fs.existsSync(targetDir) && force) {
|
|
389
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
392
|
+
createProject(projectName, template, targetDir, skipInstall);
|
|
393
|
+
console.log(`
|
|
394
|
+
Success! Created ${projectName} at ${targetDir}
|
|
395
|
+
`);
|
|
396
|
+
console.log("Next steps:\n");
|
|
397
|
+
console.log(` cd ${projectName}`);
|
|
398
|
+
console.log(" docker compose up -d");
|
|
399
|
+
console.log(" cp .env.example .env");
|
|
400
|
+
if (skipInstall) {
|
|
401
|
+
console.log(" npm install");
|
|
402
|
+
}
|
|
403
|
+
if (template === "nextjs") {
|
|
404
|
+
console.log(" npm run dev");
|
|
405
|
+
} else {
|
|
406
|
+
console.log(" node --env-file=.env src/index.ts");
|
|
407
|
+
}
|
|
408
|
+
console.log("");
|
|
409
|
+
}
|
|
410
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
411
|
+
if (process.argv[1] === __filename) {
|
|
412
|
+
main(process.argv.slice(2));
|
|
413
|
+
}
|
|
414
|
+
export {
|
|
415
|
+
createProject,
|
|
416
|
+
main,
|
|
417
|
+
parseArgs,
|
|
418
|
+
validateProjectName
|
|
419
|
+
};
|
package/package.json
CHANGED
|
@@ -1,15 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stratum-hq/create",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Create a new Stratum multi-tenancy project",
|
|
5
|
-
"bin": {
|
|
5
|
+
"bin": {
|
|
6
|
+
"create-stratum": "./dist/index.js"
|
|
7
|
+
},
|
|
6
8
|
"type": "module",
|
|
7
9
|
"scripts": {
|
|
8
10
|
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --external:@stratum-hq/*",
|
|
9
11
|
"test": "vitest run"
|
|
10
12
|
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"esbuild": "^0.20.0",
|
|
15
|
+
"vitest": "^1.6.0",
|
|
16
|
+
"typescript": "^5.3.0"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"registry": "https://registry.npmjs.org/"
|
|
21
|
+
},
|
|
13
22
|
"license": "MIT",
|
|
14
|
-
"repository": {
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/stratum-hq/Stratum.git",
|
|
26
|
+
"directory": "packages/create"
|
|
27
|
+
}
|
|
15
28
|
}
|