@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 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
+ }