@stratum-hq/create 0.3.0 → 0.4.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 +40 -0
- package/dist/index.js +108 -68
- package/dist/matrix.js +115 -0
- package/package.json +13 -8
- package/src/generators/db-setup.ts +7 -9
- package/src/generators/init-sql.ts +47 -3
- package/src/generators/middleware.ts +30 -21
- package/src/index.ts +18 -12
- package/src/preset-project.ts +8 -3
- package/src/__tests__/create.test.ts +0 -217
- package/src/__tests__/matrix.test.ts +0 -283
- package/src/__tests__/preset.test.ts +0 -266
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import type { StackPreset } from "../matrix.js";
|
|
2
2
|
|
|
3
|
+
/** Name of the non-superuser role a generated app connects as. */
|
|
4
|
+
export function postgresAppRole(dbName: string): string {
|
|
5
|
+
return `${dbName}_app`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Local development password for the generated application role. */
|
|
9
|
+
export const POSTGRES_APP_PASSWORD = "dev_app_password";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SQL that creates the role the application connects as. POSTGRES_USER is a
|
|
13
|
+
* superuser, and a superuser (or any BYPASSRLS role) ignores every row-level
|
|
14
|
+
* security policy, FORCE included. So the app gets its own role, and the
|
|
15
|
+
* superuser is kept for bootstrap and migrations.
|
|
16
|
+
*/
|
|
17
|
+
export function postgresAppRoleSql(dbName: string, strategy?: string): string {
|
|
18
|
+
const role = postgresAppRole(dbName);
|
|
19
|
+
let strategyGrant = "";
|
|
20
|
+
if (strategy === "schema") {
|
|
21
|
+
strategyGrant = `
|
|
22
|
+
-- schema-per-tenant: the app creates one schema per tenant
|
|
23
|
+
GRANT CREATE ON DATABASE ${dbName} TO ${role};
|
|
24
|
+
`;
|
|
25
|
+
} else if (strategy === "database") {
|
|
26
|
+
strategyGrant = `
|
|
27
|
+
-- database-per-tenant: the app creates one database per tenant
|
|
28
|
+
ALTER ROLE ${role} CREATEDB;
|
|
29
|
+
`;
|
|
30
|
+
}
|
|
31
|
+
return `
|
|
32
|
+
-- Application role. The app connects as ${role} (DATABASE_URL), never as the
|
|
33
|
+
-- bootstrap superuser (DATABASE_ADMIN_URL): a superuser or BYPASSRLS role
|
|
34
|
+
-- ignores every row-level security policy, FORCE included. Use the superuser
|
|
35
|
+
-- only for bootstrap and migrations.
|
|
36
|
+
CREATE ROLE ${role} WITH LOGIN PASSWORD '${POSTGRES_APP_PASSWORD}' NOSUPERUSER NOBYPASSRLS NOCREATEROLE;
|
|
37
|
+
GRANT CONNECT ON DATABASE ${dbName} TO ${role};
|
|
38
|
+
GRANT USAGE, CREATE ON SCHEMA public TO ${role};
|
|
39
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${role};
|
|
40
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${role};
|
|
41
|
+
${strategyGrant}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
3
44
|
export function generatePresetInitSql(projectName: string, preset: StackPreset): string | null {
|
|
4
45
|
const dbName = projectName.replace(/[^a-z0-9]/gi, "_").toLowerCase();
|
|
5
46
|
|
|
@@ -22,10 +63,13 @@ function generatePostgresInit(projectName: string, dbName: string, strategy: str
|
|
|
22
63
|
-- Add RLS policies to each tenant-scoped table:
|
|
23
64
|
--
|
|
24
65
|
-- ALTER TABLE your_table ENABLE ROW LEVEL SECURITY;
|
|
66
|
+
-- ALTER TABLE your_table FORCE ROW LEVEL SECURITY;
|
|
25
67
|
-- CREATE POLICY tenant_isolation ON your_table
|
|
26
|
-
-- USING (tenant_id = current_setting('app.
|
|
68
|
+
-- USING (tenant_id = current_setting('app.current_tenant_id')::uuid);
|
|
27
69
|
--
|
|
28
|
-
--
|
|
70
|
+
-- FORCE makes the policy apply to the table owner too; without it, a table
|
|
71
|
+
-- created by the application role is not isolated for that role.
|
|
72
|
+
-- The Stratum db-adapters package sets app.current_tenant_id automatically.
|
|
29
73
|
`;
|
|
30
74
|
}
|
|
31
75
|
|
|
@@ -37,7 +81,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
|
|
|
37
81
|
-- The ltree extension enables hierarchical tenant trees
|
|
38
82
|
-- uuid-ossp provides uuid_generate_v4() for tenant IDs
|
|
39
83
|
COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
|
|
40
|
-
${rlsBlock}`;
|
|
84
|
+
${postgresAppRoleSql(dbName, strategy)}${rlsBlock}`;
|
|
41
85
|
}
|
|
42
86
|
|
|
43
87
|
function generateMysqlInit(projectName: string, dbName: string): string {
|
|
@@ -33,11 +33,13 @@ const port = Number(process.env.PORT) || 3000;
|
|
|
33
33
|
|
|
34
34
|
app.use(express.json());
|
|
35
35
|
|
|
36
|
-
// Tenant extraction middleware
|
|
36
|
+
// Tenant extraction middleware. The tenant comes from the subdomain the
|
|
37
|
+
// request was routed to. Do not take it from a client-supplied header such as
|
|
38
|
+
// x-tenant-id: any caller can set one and pick another tenant. Once you add
|
|
39
|
+
// authentication, check that the signed-in user belongs to this tenant, or
|
|
40
|
+
// derive the tenant from the verified session or JWT instead.
|
|
37
41
|
app.use((req, _res, next) => {
|
|
38
|
-
const tenantId =
|
|
39
|
-
req.headers["x-tenant-id"] as string ||
|
|
40
|
-
req.hostname.split(".")[0];
|
|
42
|
+
const tenantId = req.hostname.split(".")[0];
|
|
41
43
|
(req as any).tenantId = tenantId;
|
|
42
44
|
next();
|
|
43
45
|
});
|
|
@@ -68,12 +70,14 @@ function generateFastifyMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
68
70
|
const fastify = Fastify({ logger: true });
|
|
69
71
|
const port = Number(process.env.PORT) || 3000;
|
|
70
72
|
|
|
71
|
-
// Tenant extraction
|
|
73
|
+
// Tenant extraction hook. The tenant comes from the subdomain the
|
|
74
|
+
// request was routed to. Do not take it from a client-supplied header such as
|
|
75
|
+
// x-tenant-id: any caller can set one and pick another tenant. Once you add
|
|
76
|
+
// authentication, check that the signed-in user belongs to this tenant, or
|
|
77
|
+
// derive the tenant from the verified session or JWT instead.
|
|
72
78
|
fastify.decorateRequest("tenantId", "");
|
|
73
79
|
fastify.addHook("onRequest", async (request) => {
|
|
74
|
-
const tenantId =
|
|
75
|
-
(request.headers["x-tenant-id"] as string) ||
|
|
76
|
-
(request.hostname?.split(".")[0] ?? "");
|
|
80
|
+
const tenantId = request.hostname?.split(".")[0] ?? "";
|
|
77
81
|
(request as any).tenantId = tenantId;
|
|
78
82
|
});
|
|
79
83
|
|
|
@@ -105,14 +109,15 @@ function generateNextjsMiddleware(projectName: string): MiddlewareFile[] {
|
|
|
105
109
|
import { NextRequest, NextResponse } from "next/server";
|
|
106
110
|
|
|
107
111
|
export function middleware(request: NextRequest) {
|
|
112
|
+
// The tenant comes from the subdomain the request was routed to. Any
|
|
113
|
+
// x-tenant-id the client sent is removed first, so server code that reads
|
|
114
|
+
// x-tenant-id only ever sees the value set here. Once you add
|
|
115
|
+
// authentication, check that the signed-in user belongs to this tenant.
|
|
108
116
|
const hostname = request.headers.get("host") || "";
|
|
109
|
-
const
|
|
110
|
-
const headerTenantId = request.headers.get("x-tenant-id");
|
|
111
|
-
const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
|
|
112
|
-
|
|
113
|
-
const tenantId = headerTenantId || pathTenantId || subdomain;
|
|
117
|
+
const tenantId = hostname.split(".")[0];
|
|
114
118
|
|
|
115
119
|
const requestHeaders = new Headers(request.headers);
|
|
120
|
+
requestHeaders.delete("x-tenant-id");
|
|
116
121
|
if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
|
|
117
122
|
requestHeaders.set("x-tenant-id", tenantId);
|
|
118
123
|
}
|
|
@@ -135,7 +140,7 @@ export default function Home() {
|
|
|
135
140
|
<p>Multi-tenant app powered by Stratum.</p>
|
|
136
141
|
<ul>
|
|
137
142
|
<li>Configure tenants via the Stratum control plane</li>
|
|
138
|
-
<li>
|
|
143
|
+
<li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
|
|
139
144
|
<li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
|
|
140
145
|
</ul>
|
|
141
146
|
</main>
|
|
@@ -155,11 +160,13 @@ import { serve } from "@hono/node-server";
|
|
|
155
160
|
|
|
156
161
|
const app = new Hono();
|
|
157
162
|
|
|
158
|
-
// Tenant extraction middleware
|
|
163
|
+
// Tenant extraction middleware. The tenant comes from the subdomain the
|
|
164
|
+
// request was routed to. Do not take it from a client-supplied header such as
|
|
165
|
+
// x-tenant-id: any caller can set one and pick another tenant. Once you add
|
|
166
|
+
// authentication, check that the signed-in user belongs to this tenant, or
|
|
167
|
+
// derive the tenant from the verified session or JWT instead.
|
|
159
168
|
app.use("*", async (c, next) => {
|
|
160
|
-
const tenantId =
|
|
161
|
-
c.req.header("x-tenant-id") ||
|
|
162
|
-
new URL(c.req.url).hostname.split(".")[0];
|
|
169
|
+
const tenantId = new URL(c.req.url).hostname.split(".")[0];
|
|
163
170
|
c.set("tenantId", tenantId);
|
|
164
171
|
await next();
|
|
165
172
|
});
|
|
@@ -239,9 +246,11 @@ export class AppController {
|
|
|
239
246
|
export class TenantGuard implements CanActivate {
|
|
240
247
|
canActivate(context: ExecutionContext): boolean {
|
|
241
248
|
const request = context.switchToHttp().getRequest();
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
249
|
+
// The tenant comes from the subdomain the request was routed to. Do not
|
|
250
|
+
// take it from a client-supplied header such as x-tenant-id: any caller
|
|
251
|
+
// can set one and pick another tenant. Once you add authentication, check
|
|
252
|
+
// that the signed-in user belongs to this tenant here.
|
|
253
|
+
const tenantId = request.hostname?.split(".")[0];
|
|
245
254
|
request.tenantId = tenantId || null;
|
|
246
255
|
return true;
|
|
247
256
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,9 @@ import * as path from "path";
|
|
|
5
5
|
import crypto from "node:crypto";
|
|
6
6
|
import { execSync } from "child_process";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
|
-
import { parsePresetString, isValidPreset
|
|
8
|
+
import { parsePresetString, isValidPreset } from "./matrix.js";
|
|
9
9
|
import { createPresetProject } from "./preset-project.js";
|
|
10
|
+
import { postgresAppRole, postgresAppRoleSql, POSTGRES_APP_PASSWORD } from "./generators/init-sql.js";
|
|
10
11
|
|
|
11
12
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
12
13
|
|
|
@@ -194,7 +195,7 @@ CREATE EXTENSION IF NOT EXISTS "ltree";
|
|
|
194
195
|
-- The ltree extension enables hierarchical tenant trees
|
|
195
196
|
-- uuid-ossp provides uuid_generate_v4() for tenant IDs
|
|
196
197
|
COMMENT ON DATABASE ${dbName} IS 'Multi-tenant database for ${projectName}';
|
|
197
|
-
`;
|
|
198
|
+
${postgresAppRoleSql(dbName)}`;
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
function generateEnv(projectName: string): string {
|
|
@@ -203,8 +204,12 @@ function generateEnv(projectName: string): string {
|
|
|
203
204
|
return `# Environment variables for ${projectName}
|
|
204
205
|
# Copy to .env and fill in values
|
|
205
206
|
|
|
206
|
-
# Database
|
|
207
|
-
|
|
207
|
+
# Database. The app connects as the non-superuser role created in init.sql,
|
|
208
|
+
# so row-level security applies to it.
|
|
209
|
+
DATABASE_URL=postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}
|
|
210
|
+
|
|
211
|
+
# Superuser: bootstrap and migrations only. It bypasses row-level security.
|
|
212
|
+
DATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}
|
|
208
213
|
|
|
209
214
|
# Authentication
|
|
210
215
|
JWT_SECRET=${jwtSecret}
|
|
@@ -295,7 +300,7 @@ export default function Home() {
|
|
|
295
300
|
<p>Multi-tenant app powered by Stratum.</p>
|
|
296
301
|
<ul>
|
|
297
302
|
<li>Configure tenants via the Stratum control plane</li>
|
|
298
|
-
<li>
|
|
303
|
+
<li>Tenant is resolved from the subdomain in <code>middleware.ts</code></li>
|
|
299
304
|
<li>Use <code>@stratum-hq/lib</code> for tenant resolution</li>
|
|
300
305
|
</ul>
|
|
301
306
|
</main>
|
|
@@ -305,18 +310,19 @@ export default function Home() {
|
|
|
305
310
|
}
|
|
306
311
|
|
|
307
312
|
function generateNextjsMiddleware(): string {
|
|
308
|
-
return `// middleware.ts — tenant resolution via subdomain
|
|
313
|
+
return `// middleware.ts — tenant resolution via subdomain
|
|
309
314
|
import { NextRequest, NextResponse } from "next/server";
|
|
310
315
|
|
|
311
316
|
export function middleware(request: NextRequest) {
|
|
317
|
+
// The tenant comes from the subdomain the request was routed to. Any
|
|
318
|
+
// x-tenant-id the client sent is removed first, so server code that reads
|
|
319
|
+
// x-tenant-id only ever sees the value set here. Once you add
|
|
320
|
+
// authentication, check that the signed-in user belongs to this tenant.
|
|
312
321
|
const hostname = request.headers.get("host") || "";
|
|
313
|
-
const
|
|
314
|
-
const headerTenantId = request.headers.get("x-tenant-id");
|
|
315
|
-
const pathTenantId = request.nextUrl.pathname.match(/^\\/tenant\\/([^/]+)/)?.[1];
|
|
316
|
-
|
|
317
|
-
const tenantId = headerTenantId || pathTenantId || subdomain;
|
|
322
|
+
const tenantId = hostname.split(".")[0];
|
|
318
323
|
|
|
319
324
|
const requestHeaders = new Headers(request.headers);
|
|
325
|
+
requestHeaders.delete("x-tenant-id");
|
|
320
326
|
if (tenantId && tenantId !== "localhost" && tenantId !== "www") {
|
|
321
327
|
requestHeaders.set("x-tenant-id", tenantId);
|
|
322
328
|
}
|
|
@@ -380,7 +386,7 @@ ${projectName}/
|
|
|
380
386
|
|
|
381
387
|
This project uses Stratum for hierarchical multi-tenancy:
|
|
382
388
|
|
|
383
|
-
- **Tenant resolution** — via
|
|
389
|
+
- **Tenant resolution** — via subdomain (see \`middleware.ts\`); bind it to the signed-in user once you add authentication
|
|
384
390
|
- **Config inheritance** — settings flow down the tenant tree with override support
|
|
385
391
|
- **Permission ABAC** — role-based permissions with tenant-scoped enforcement
|
|
386
392
|
|
package/src/preset-project.ts
CHANGED
|
@@ -4,7 +4,7 @@ import crypto from "node:crypto";
|
|
|
4
4
|
import { execSync } from "child_process";
|
|
5
5
|
import type { StackPreset } from "./matrix.js";
|
|
6
6
|
import { generatePresetDockerCompose } from "./generators/docker-compose.js";
|
|
7
|
-
import { generatePresetInitSql } from "./generators/init-sql.js";
|
|
7
|
+
import { generatePresetInitSql, postgresAppRole, POSTGRES_APP_PASSWORD } from "./generators/init-sql.js";
|
|
8
8
|
import { generateDbSetup } from "./generators/db-setup.js";
|
|
9
9
|
import { generateMiddleware } from "./generators/middleware.js";
|
|
10
10
|
import { generatePresetPackageJson } from "./generators/package-json.js";
|
|
@@ -24,9 +24,14 @@ function generatePresetEnv(projectName: string, preset: StackPreset): string {
|
|
|
24
24
|
const jwtSecret = crypto.randomBytes(32).toString("base64url");
|
|
25
25
|
|
|
26
26
|
let dbUrl: string;
|
|
27
|
+
let adminUrlLine = "";
|
|
27
28
|
switch (preset.database) {
|
|
28
29
|
case "postgres":
|
|
29
|
-
|
|
30
|
+
// The app connects as the non-superuser role created in init.sql, so
|
|
31
|
+
// row-level security applies to it. The superuser URL is for bootstrap
|
|
32
|
+
// and migrations only.
|
|
33
|
+
dbUrl = `postgres://${postgresAppRole(dbName)}:${POSTGRES_APP_PASSWORD}@localhost:5432/${dbName}`;
|
|
34
|
+
adminUrlLine = `\n# Superuser: bootstrap and migrations only. It bypasses row-level security.\nDATABASE_ADMIN_URL=postgres://${dbName}:dev_password@localhost:5432/${dbName}\n`;
|
|
30
35
|
break;
|
|
31
36
|
case "mongodb":
|
|
32
37
|
dbUrl = `mongodb://${dbName}:dev_password@localhost:27017/${dbName}?authSource=admin`;
|
|
@@ -43,7 +48,7 @@ function generatePresetEnv(projectName: string, preset: StackPreset): string {
|
|
|
43
48
|
|
|
44
49
|
# Database
|
|
45
50
|
${urlKey}=${dbUrl}
|
|
46
|
-
|
|
51
|
+
${adminUrlLine}
|
|
47
52
|
# Authentication
|
|
48
53
|
JWT_SECRET=${jwtSecret}
|
|
49
54
|
|
|
@@ -1,217 +0,0 @@
|
|
|
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
|
-
});
|