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