@cosmicdrift/kumiko-dev-server 0.105.2 → 0.108.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/package.json +3 -3
- package/src/__tests__/compose-features-wiring.integration.test.ts +9 -9
- package/src/__tests__/require-env.test.ts +29 -0
- package/src/__tests__/scaffold-app-feature.test.ts +2 -2
- package/src/__tests__/scaffold-app.test.ts +137 -81
- package/src/__tests__/walkthrough.integration.test.ts +13 -19
- package/src/run-dev-app.ts +34 -4
- package/src/run-prod-app.ts +33 -14
- package/src/scaffold-app.ts +297 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.108.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
53
|
+
"@cosmicdrift/kumiko-bundled-features": "0.108.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.108.0",
|
|
55
55
|
"ts-morph": "^28.0.0"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
@@ -50,7 +50,6 @@ import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-featu
|
|
|
50
50
|
import { tenantEntity, tenantMembershipsTable } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
51
51
|
import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/testing";
|
|
52
52
|
import { UserHandlers, userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
53
|
-
import { deleteMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
54
53
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
55
54
|
import {
|
|
56
55
|
setupTestStack,
|
|
@@ -59,6 +58,7 @@ import {
|
|
|
59
58
|
unsafeCreateEntityTable,
|
|
60
59
|
unsafePushTables,
|
|
61
60
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
61
|
+
import { deleteRows } from "@cosmicdrift/kumiko-framework/testing";
|
|
62
62
|
import { composeFeatures } from "../compose-features";
|
|
63
63
|
|
|
64
64
|
const RESET_HMAC = randomBytes(32).toString("base64");
|
|
@@ -184,8 +184,8 @@ describe("composeFeatures wiring — passwordReset", () => {
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
beforeEach(async () => {
|
|
187
|
-
await
|
|
188
|
-
await
|
|
187
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
188
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
189
189
|
suite.emailTransport.sent.length = 0;
|
|
190
190
|
});
|
|
191
191
|
|
|
@@ -257,8 +257,8 @@ describe("composeFeatures wiring — emailVerification", () => {
|
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
beforeEach(async () => {
|
|
260
|
-
await
|
|
261
|
-
await
|
|
260
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
261
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
262
262
|
suite.emailTransport.sent.length = 0;
|
|
263
263
|
});
|
|
264
264
|
|
|
@@ -302,8 +302,8 @@ describe("composeFeatures wiring — asymmetric activation", () => {
|
|
|
302
302
|
});
|
|
303
303
|
|
|
304
304
|
beforeEach(async () => {
|
|
305
|
-
await
|
|
306
|
-
await
|
|
305
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
306
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
307
307
|
suite.emailTransport.sent.length = 0;
|
|
308
308
|
});
|
|
309
309
|
|
|
@@ -355,8 +355,8 @@ describe("composeFeatures wiring — fail-closed ohne authOptions", () => {
|
|
|
355
355
|
});
|
|
356
356
|
|
|
357
357
|
afterEach(async () => {
|
|
358
|
-
await
|
|
359
|
-
await
|
|
358
|
+
await deleteRows(suite.stack.db, userTable, {});
|
|
359
|
+
await deleteRows(suite.stack.db, tenantMembershipsTable, {});
|
|
360
360
|
suite.emailTransport.sent.length = 0;
|
|
361
361
|
});
|
|
362
362
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// requireEnv — the missing-var error message must match the actual caller.
|
|
2
|
+
// Regression (728/2): the advice text was hardcoded to prod/container
|
|
3
|
+
// wording ("Coolify secrets") regardless of the `context` param, so a
|
|
4
|
+
// runDevApp caller got misleading production-deploy instructions for a
|
|
5
|
+
// missing local .env var.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { requireEnv } from "../run-prod-app";
|
|
9
|
+
|
|
10
|
+
describe("requireEnv", () => {
|
|
11
|
+
test("default context (runProdApp) gives container/production advice", () => {
|
|
12
|
+
expect(() => requireEnv("MISSING_VAR", {})).toThrow(/container env.*Coolify secrets/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("runDevApp context gives local .env advice, not production advice", () => {
|
|
16
|
+
expect(() => requireEnv("MISSING_VAR", {}, "runDevApp")).toThrow(
|
|
17
|
+
/\.env \/ shell before running the dev server/,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("runDevApp context error does NOT mention Coolify", () => {
|
|
22
|
+
try {
|
|
23
|
+
requireEnv("MISSING_VAR", {}, "runDevApp");
|
|
24
|
+
throw new Error("expected requireEnv to throw");
|
|
25
|
+
} catch (err) {
|
|
26
|
+
expect(String(err)).not.toContain("Coolify");
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -10,10 +10,10 @@ import { scaffoldAppFeature } from "../scaffold-app-feature";
|
|
|
10
10
|
describe("scaffoldAppFeature", () => {
|
|
11
11
|
let tmp: string;
|
|
12
12
|
let appRoot: string;
|
|
13
|
-
beforeEach(() => {
|
|
13
|
+
beforeEach(async () => {
|
|
14
14
|
tmp = mkdtempSync(join(tmpdir(), "scaffold-app-feature-"));
|
|
15
15
|
appRoot = join(tmp, "my-shop");
|
|
16
|
-
scaffoldApp({ name: "my-shop", destination: appRoot });
|
|
16
|
+
await scaffoldApp({ name: "my-shop", destination: appRoot });
|
|
17
17
|
});
|
|
18
18
|
afterEach(() => {
|
|
19
19
|
rmSync(tmp, { recursive: true, force: true });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// scaffoldApp unit-tests (DX-1.0).
|
|
1
|
+
// scaffoldApp unit-tests (DX-1.0 + #352 deploy/schema scaffold).
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
4
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
@@ -6,6 +6,29 @@ import { tmpdir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { scaffoldApp } from "../scaffold-app";
|
|
8
8
|
|
|
9
|
+
const SCAFFOLD_FILES = [
|
|
10
|
+
"package.json",
|
|
11
|
+
"tsconfig.json",
|
|
12
|
+
"biome.json",
|
|
13
|
+
"bunfig.toml",
|
|
14
|
+
"bunfig.ci.toml",
|
|
15
|
+
"src/run-config.ts",
|
|
16
|
+
"kumiko/schema.ts",
|
|
17
|
+
"bin/main.ts",
|
|
18
|
+
"bin/dev.ts",
|
|
19
|
+
"bin/kumiko.ts",
|
|
20
|
+
"src/client.tsx",
|
|
21
|
+
"src/styles.css",
|
|
22
|
+
".env.example",
|
|
23
|
+
"docker-compose.yml",
|
|
24
|
+
"kumiko/migrations/0001_init.sql",
|
|
25
|
+
"kumiko/migrations/.snapshot.json",
|
|
26
|
+
"deploy/Dockerfile",
|
|
27
|
+
"deploy/Dockerfile.dockerignore",
|
|
28
|
+
"deploy/migrate-step.sh",
|
|
29
|
+
"README.md",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
9
32
|
describe("scaffoldApp", () => {
|
|
10
33
|
let tmp: string;
|
|
11
34
|
beforeEach(() => {
|
|
@@ -15,31 +38,21 @@ describe("scaffoldApp", () => {
|
|
|
15
38
|
rmSync(tmp, { recursive: true, force: true });
|
|
16
39
|
});
|
|
17
40
|
|
|
18
|
-
test("scaffolds the expected files into <cwd>/<name>", () => {
|
|
41
|
+
test("scaffolds the expected files into <cwd>/<name>", async () => {
|
|
19
42
|
const dest = join(tmp, "my-shop");
|
|
20
|
-
const result = scaffoldApp({ name: "my-shop", destination: dest });
|
|
43
|
+
const result = await scaffoldApp({ name: "my-shop", destination: dest });
|
|
21
44
|
|
|
22
45
|
expect(result.appName).toBe("my-shop");
|
|
23
46
|
expect(result.destination).toBe(dest);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"tsconfig.json",
|
|
27
|
-
"src/run-config.ts",
|
|
28
|
-
"bin/main.ts",
|
|
29
|
-
"bin/dev.ts",
|
|
30
|
-
"src/client.tsx",
|
|
31
|
-
".env.example",
|
|
32
|
-
"docker-compose.yml",
|
|
33
|
-
"README.md",
|
|
34
|
-
]);
|
|
35
|
-
for (const f of result.files) {
|
|
47
|
+
for (const f of SCAFFOLD_FILES) {
|
|
48
|
+
expect(result.files).toContain(f);
|
|
36
49
|
expect(existsSync(join(dest, f))).toBe(true);
|
|
37
50
|
}
|
|
38
51
|
});
|
|
39
52
|
|
|
40
|
-
test("package.json has @cosmicdrift/* deps with version pin", () => {
|
|
53
|
+
test("package.json has @cosmicdrift/* deps with version pin", async () => {
|
|
41
54
|
const dest = join(tmp, "my-shop");
|
|
42
|
-
scaffoldApp({ name: "my-shop", destination: dest, frameworkVersion: "^0.13.0" });
|
|
55
|
+
await scaffoldApp({ name: "my-shop", destination: dest, frameworkVersion: "^0.13.0" });
|
|
43
56
|
|
|
44
57
|
const pkg = JSON.parse(readFileSync(join(dest, "package.json"), "utf-8")) as {
|
|
45
58
|
name: string;
|
|
@@ -53,70 +66,82 @@ describe("scaffoldApp", () => {
|
|
|
53
66
|
expect(pkg.dependencies["@cosmicdrift/kumiko-renderer-web"]).toBe("^0.13.0");
|
|
54
67
|
expect(pkg.scripts["boot"]).toContain("KUMIKO_DRY_RUN_ENV=boot");
|
|
55
68
|
expect(pkg.scripts["dev"]).toBe("bun --watch bin/dev.ts");
|
|
69
|
+
expect(pkg.scripts["build"]).toBe("bun kumiko-build");
|
|
70
|
+
expect(pkg.scripts["start"]).toBe("bun run bin/main.ts");
|
|
71
|
+
expect(pkg.scripts["lint"]).toBe("biome check .");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("init migration includes auth-mode tables (read_users)", async () => {
|
|
75
|
+
const dest = join(tmp, "my-shop");
|
|
76
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
77
|
+
|
|
78
|
+
const sql = readFileSync(join(dest, "kumiko/migrations/0001_init.sql"), "utf-8");
|
|
79
|
+
expect(sql).toContain('CREATE TABLE IF NOT EXISTS "read_users"');
|
|
56
80
|
});
|
|
57
81
|
|
|
58
|
-
test("
|
|
82
|
+
test("kumiko/schema.ts + bin/kumiko.ts wire HAS_AUTH single-source", async () => {
|
|
59
83
|
const dest = join(tmp, "my-shop");
|
|
60
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
84
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
85
|
+
|
|
86
|
+
const schema = readFileSync(join(dest, "kumiko/schema.ts"), "utf-8");
|
|
87
|
+
expect(schema).toContain("APP_FEATURES, HAS_AUTH");
|
|
88
|
+
expect(schema).toContain("collectTableMetas");
|
|
89
|
+
|
|
90
|
+
const kumikoBin = readFileSync(join(dest, "bin/kumiko.ts"), "utf-8");
|
|
91
|
+
expect(kumikoBin).toContain("runSchemaCli");
|
|
92
|
+
expect(kumikoBin).toContain("includeBundled: HAS_AUTH");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("bin/dev.ts contains runDevApp + welcomeBanner + admin login + clientEntry", async () => {
|
|
96
|
+
const dest = join(tmp, "my-shop");
|
|
97
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
61
98
|
|
|
62
99
|
const dev = readFileSync(join(dest, "bin/dev.ts"), "utf-8");
|
|
63
100
|
expect(dev).toContain("runDevApp");
|
|
64
101
|
expect(dev).toContain("welcomeBanner: true");
|
|
65
102
|
expect(dev).toContain("admin@my-shop.local");
|
|
66
103
|
expect(dev).toContain(`password: "changeme"`);
|
|
67
|
-
// Without clientEntry the default HTML still <script src="/client.js">'s,
|
|
68
|
-
// and the dev-server 404s on it — every fresh scaffold rendered blank.
|
|
69
104
|
expect(dev).toContain(`clientEntry: "./src/client.tsx"`);
|
|
70
105
|
});
|
|
71
106
|
|
|
72
|
-
test("src/client.tsx bundles createKumikoApp + emailPasswordClient + DefaultAppShell", () => {
|
|
107
|
+
test("src/client.tsx bundles createKumikoApp + emailPasswordClient + DefaultAppShell", async () => {
|
|
73
108
|
const dest = join(tmp, "my-shop");
|
|
74
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
109
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
75
110
|
|
|
76
111
|
const client = readFileSync(join(dest, "src/client.tsx"), "utf-8");
|
|
77
112
|
expect(client).toContain("createKumikoApp");
|
|
78
113
|
expect(client).toContain("emailPasswordClient");
|
|
79
|
-
// Without shell:DefaultAppShell post-login renders only the active
|
|
80
|
-
// screen (no sidebar / topbar) — the e2e hero-demo proved this dead-
|
|
81
|
-
// ends on "Screen not found" because routing has no layout chrome.
|
|
82
114
|
expect(client).toContain("DefaultAppShell");
|
|
83
115
|
expect(client).toContain("shell: DefaultAppShell");
|
|
84
116
|
expect(client).toContain('from "@cosmicdrift/kumiko-renderer-web"');
|
|
85
117
|
expect(client).toContain('from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web"');
|
|
86
118
|
});
|
|
87
119
|
|
|
88
|
-
test(".env.example carries KUMIKO_DEV_DB_NAME default so reboots are persistent", () => {
|
|
120
|
+
test(".env.example carries KUMIKO_DEV_DB_NAME default so reboots are persistent", async () => {
|
|
89
121
|
const dest = join(tmp, "my-shop");
|
|
90
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
122
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
91
123
|
|
|
92
124
|
const env = readFileSync(join(dest, ".env.example"), "utf-8");
|
|
93
125
|
expect(env).toContain("KUMIKO_DEV_DB_NAME=my_shop_dev");
|
|
94
126
|
});
|
|
95
127
|
|
|
96
|
-
test(".env.example lists both TEST_DATABASE_URL (bun dev) + DATABASE_URL (prod)", () => {
|
|
128
|
+
test(".env.example lists both TEST_DATABASE_URL (bun dev) + DATABASE_URL (prod)", async () => {
|
|
97
129
|
const dest = join(tmp, "my-shop");
|
|
98
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
130
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
99
131
|
|
|
100
132
|
const env = readFileSync(join(dest, ".env.example"), "utf-8");
|
|
101
|
-
// runDevApp → setupTestStack needs TEST_DATABASE_URL; previously the
|
|
102
|
-
// template only listed DATABASE_URL, so every fresh `bun dev` crashed
|
|
103
|
-
// with "Missing required env var: TEST_DATABASE_URL".
|
|
104
133
|
expect(env).toContain("TEST_DATABASE_URL=");
|
|
105
134
|
expect(env).toContain("DATABASE_URL=");
|
|
106
135
|
});
|
|
107
136
|
|
|
108
|
-
test("docker-compose.yml ports + credentials match the .env.example *_URL defaults", () => {
|
|
137
|
+
test("docker-compose.yml ports + credentials match the .env.example *_URL defaults", async () => {
|
|
109
138
|
const dest = join(tmp, "my-shop");
|
|
110
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
139
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
111
140
|
|
|
112
141
|
const compose = readFileSync(join(dest, "docker-compose.yml"), "utf-8");
|
|
113
142
|
const env = readFileSync(join(dest, ".env.example"), "utf-8");
|
|
114
|
-
// README tells the user to run `docker compose up -d`; that only works if
|
|
115
|
-
// the compose service matches what the generated .env points its *_URLs at.
|
|
116
143
|
expect(env).toContain("127.0.0.1:5432");
|
|
117
144
|
expect(env).toContain("127.0.0.1:6379");
|
|
118
|
-
// Ports bind to loopback only — weak dev creds / auth-less Redis must not
|
|
119
|
-
// be reachable from the LAN (security review of the scaffold).
|
|
120
145
|
expect(compose).toContain('"127.0.0.1:5432:5432"');
|
|
121
146
|
expect(compose).toContain('"127.0.0.1:6379:6379"');
|
|
122
147
|
expect(compose).not.toContain('"5432:5432"');
|
|
@@ -126,9 +151,9 @@ describe("scaffoldApp", () => {
|
|
|
126
151
|
expect(compose).toContain("image: redis:");
|
|
127
152
|
});
|
|
128
153
|
|
|
129
|
-
test("README lists the mounted features dynamically", () => {
|
|
154
|
+
test("README lists the mounted features dynamically", async () => {
|
|
130
155
|
const dest = join(tmp, "my-shop");
|
|
131
|
-
scaffoldApp({
|
|
156
|
+
await scaffoldApp({
|
|
132
157
|
name: "my-shop",
|
|
133
158
|
destination: dest,
|
|
134
159
|
features: [
|
|
@@ -153,78 +178,75 @@ describe("scaffoldApp", () => {
|
|
|
153
178
|
expect(readme).toContain("- `delivery`");
|
|
154
179
|
});
|
|
155
180
|
|
|
156
|
-
test("bin/main.ts contains runProdApp + auth.admin stub", () => {
|
|
181
|
+
test("bin/main.ts contains runProdApp + auth.admin stub + staticDir", async () => {
|
|
157
182
|
const dest = join(tmp, "my-shop");
|
|
158
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
183
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
159
184
|
|
|
160
185
|
const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
|
|
161
186
|
expect(main).toContain("runProdApp");
|
|
187
|
+
expect(main).toContain('staticDir: "./dist"');
|
|
162
188
|
expect(main).toContain("auth: {");
|
|
163
189
|
expect(main).toContain("admin@my-shop.local");
|
|
164
190
|
expect(main).toContain('tenantKey: "my-shop"');
|
|
165
|
-
// Tenant-ID is a valid UUID-v4 format (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx).
|
|
166
191
|
expect(main).toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/);
|
|
167
192
|
});
|
|
168
193
|
|
|
169
|
-
test("bin/main.ts composes the auth-mode feature set into envSchema (JWT_SECRET boot-gate)", () => {
|
|
194
|
+
test("bin/main.ts composes the auth-mode feature set into envSchema (JWT_SECRET boot-gate)", async () => {
|
|
170
195
|
const dest = join(tmp, "my-shop");
|
|
171
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
196
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
172
197
|
|
|
173
198
|
const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
|
|
174
|
-
|
|
175
|
-
// auth-mode — otherwise auth-email-password's JWT_SECRET (min-32) is
|
|
176
|
-
// absent from the boot-gate and a too-short secret slips through.
|
|
177
|
-
expect(main).toContain("composeFeatures(APP_FEATURES, { includeBundled: true })");
|
|
199
|
+
expect(main).toContain("composeFeatures(APP_FEATURES, { includeBundled: HAS_AUTH })");
|
|
178
200
|
expect(main).toContain(
|
|
179
201
|
"composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures })",
|
|
180
202
|
);
|
|
181
|
-
expect(main).toContain("composeFeatures");
|
|
182
203
|
});
|
|
183
204
|
|
|
184
|
-
test("src/run-config.ts mounts secrets + sessions
|
|
205
|
+
test("src/run-config.ts mounts secrets + sessions + HAS_AUTH", async () => {
|
|
185
206
|
const dest = join(tmp, "my-shop");
|
|
186
|
-
scaffoldApp({ name: "my-shop", destination: dest });
|
|
207
|
+
await scaffoldApp({ name: "my-shop", destination: dest });
|
|
187
208
|
|
|
188
209
|
const runConfig = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
|
|
189
210
|
expect(runConfig).toContain("createSecretsFeature()");
|
|
190
211
|
expect(runConfig).toContain("createSessionsFeature()");
|
|
191
212
|
expect(runConfig).toContain("export const APP_FEATURES");
|
|
213
|
+
expect(runConfig).toContain("export const HAS_AUTH");
|
|
192
214
|
});
|
|
193
215
|
|
|
194
|
-
test("rejects non-kebab-case names", () => {
|
|
195
|
-
expect(
|
|
196
|
-
expect(
|
|
197
|
-
expect(
|
|
216
|
+
test("rejects non-kebab-case names", async () => {
|
|
217
|
+
await expect(scaffoldApp({ name: "MyShop", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
218
|
+
await expect(scaffoldApp({ name: "my_shop", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
219
|
+
await expect(scaffoldApp({ name: "0shop", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
198
220
|
});
|
|
199
221
|
|
|
200
|
-
test("rejects trailing- and double-hyphen names (invalid package segment)", () => {
|
|
201
|
-
expect(
|
|
202
|
-
expect(
|
|
222
|
+
test("rejects trailing- and double-hyphen names (invalid package segment)", async () => {
|
|
223
|
+
await expect(scaffoldApp({ name: "my-shop-", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
224
|
+
await expect(scaffoldApp({ name: "my--shop", destination: tmp })).rejects.toThrow(/kebab-case/);
|
|
203
225
|
});
|
|
204
226
|
|
|
205
|
-
test("resolves a relative destination against the supplied cwd, not process.cwd()", () => {
|
|
206
|
-
|
|
207
|
-
// displayed path matches the actual write location.
|
|
208
|
-
const result = scaffoldApp({ name: "shop", destination: "apps/shop", cwd: tmp });
|
|
227
|
+
test("resolves a relative destination against the supplied cwd, not process.cwd()", async () => {
|
|
228
|
+
const result = await scaffoldApp({ name: "shop", destination: "apps/shop", cwd: tmp });
|
|
209
229
|
expect(result.destination).toBe(join(tmp, "apps/shop"));
|
|
210
230
|
expect(existsSync(join(tmp, "apps/shop", "package.json"))).toBe(true);
|
|
211
231
|
});
|
|
212
232
|
|
|
213
|
-
test("resolves the name-default destination against the supplied cwd", () => {
|
|
214
|
-
const result = scaffoldApp({ name: "shop", cwd: tmp });
|
|
233
|
+
test("resolves the name-default destination against the supplied cwd", async () => {
|
|
234
|
+
const result = await scaffoldApp({ name: "shop", cwd: tmp });
|
|
215
235
|
expect(result.destination).toBe(join(tmp, "shop"));
|
|
216
236
|
expect(existsSync(join(tmp, "shop", "package.json"))).toBe(true);
|
|
217
237
|
});
|
|
218
238
|
|
|
219
|
-
test("refuses to overwrite existing destination", () => {
|
|
239
|
+
test("refuses to overwrite existing destination", async () => {
|
|
220
240
|
const dest = join(tmp, "existing");
|
|
221
|
-
scaffoldApp({ name: "existing", destination: dest });
|
|
222
|
-
expect(
|
|
241
|
+
await scaffoldApp({ name: "existing", destination: dest });
|
|
242
|
+
await expect(scaffoldApp({ name: "existing", destination: dest })).rejects.toThrow(
|
|
243
|
+
/already exists/,
|
|
244
|
+
);
|
|
223
245
|
});
|
|
224
246
|
|
|
225
|
-
test("features-param: custom selection lands in run-config.ts imports + APP_FEATURES", () => {
|
|
247
|
+
test("features-param: custom selection lands in run-config.ts imports + APP_FEATURES", async () => {
|
|
226
248
|
const dest = join(tmp, "custom-features");
|
|
227
|
-
scaffoldApp({
|
|
249
|
+
await scaffoldApp({
|
|
228
250
|
name: "custom-features",
|
|
229
251
|
destination: dest,
|
|
230
252
|
features: [
|
|
@@ -251,23 +273,20 @@ describe("scaffoldApp", () => {
|
|
|
251
273
|
expect(cfg).not.toContain("createSessionsFeature");
|
|
252
274
|
});
|
|
253
275
|
|
|
254
|
-
test("features-param: empty array falls back to foundation (backwards-compat)", () => {
|
|
276
|
+
test("features-param: empty array falls back to foundation (backwards-compat)", async () => {
|
|
255
277
|
const dest = join(tmp, "empty-features");
|
|
256
|
-
scaffoldApp({ name: "empty-features", destination: dest, features: [] });
|
|
278
|
+
await scaffoldApp({ name: "empty-features", destination: dest, features: [] });
|
|
257
279
|
const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
|
|
258
280
|
expect(cfg).toContain("createSecretsFeature()");
|
|
259
281
|
expect(cfg).toContain("createSessionsFeature()");
|
|
260
282
|
});
|
|
261
283
|
|
|
262
|
-
test("features-param: composeFeatures auto-mounted
|
|
284
|
+
test("features-param: composeFeatures auto-mounted names are filtered out", async () => {
|
|
263
285
|
const dest = join(tmp, "filtered");
|
|
264
|
-
scaffoldApp({
|
|
286
|
+
await scaffoldApp({
|
|
265
287
|
name: "filtered",
|
|
266
288
|
destination: dest,
|
|
267
289
|
features: [
|
|
268
|
-
// Picker's recommended set includes the 4 auto-mounted bundled
|
|
269
|
-
// features; without the filter they'd land in APP_FEATURES and
|
|
270
|
-
// composeFeatures would log 4 dedupe warnings on every boot.
|
|
271
290
|
{
|
|
272
291
|
name: "config",
|
|
273
292
|
importPath: "@cosmicdrift/kumiko-bundled-features/config",
|
|
@@ -308,11 +327,48 @@ describe("scaffoldApp", () => {
|
|
|
308
327
|
expect(cfg).toContain("createDeliveryFeature()");
|
|
309
328
|
});
|
|
310
329
|
|
|
311
|
-
test("
|
|
330
|
+
test("features-param: ONLY auto-mounted names → empty effective set, no foundation fallback", async () => {
|
|
331
|
+
const dest = join(tmp, "only-auto-mounted");
|
|
332
|
+
await scaffoldApp({
|
|
333
|
+
name: "only-auto-mounted",
|
|
334
|
+
destination: dest,
|
|
335
|
+
features: [
|
|
336
|
+
{
|
|
337
|
+
name: "config",
|
|
338
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/config",
|
|
339
|
+
exportName: "createConfigFeature",
|
|
340
|
+
callExpression: "createConfigFeature()",
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
name: "user",
|
|
344
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/user",
|
|
345
|
+
exportName: "createUserFeature",
|
|
346
|
+
callExpression: "createUserFeature()",
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
name: "tenant",
|
|
350
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/tenant",
|
|
351
|
+
exportName: "createTenantFeature",
|
|
352
|
+
callExpression: "createTenantFeature()",
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: "auth-email-password",
|
|
356
|
+
importPath: "@cosmicdrift/kumiko-bundled-features/auth-email-password",
|
|
357
|
+
exportName: "createAuthEmailPasswordFeature",
|
|
358
|
+
callExpression: "createAuthEmailPasswordFeature()",
|
|
359
|
+
},
|
|
360
|
+
],
|
|
361
|
+
});
|
|
362
|
+
const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
|
|
363
|
+
expect(cfg).not.toContain("createSecretsFeature");
|
|
364
|
+
expect(cfg).not.toContain("createSessionsFeature");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("deterministic tenantId for same name (reproducible boots)", async () => {
|
|
312
368
|
const a = join(tmp, "a");
|
|
313
369
|
const b = join(tmp, "b");
|
|
314
|
-
scaffoldApp({ name: "stable", destination: a });
|
|
315
|
-
scaffoldApp({ name: "stable", destination: b });
|
|
370
|
+
await scaffoldApp({ name: "stable", destination: a });
|
|
371
|
+
await scaffoldApp({ name: "stable", destination: b });
|
|
316
372
|
const mainA = readFileSync(join(a, "bin/main.ts"), "utf-8");
|
|
317
373
|
const mainB = readFileSync(join(b, "bin/main.ts"), "utf-8");
|
|
318
374
|
const uuidA = mainA.match(
|
|
@@ -32,23 +32,17 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
32
32
|
rmSync(tmp, { recursive: true, force: true });
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
test("Step 1 (kumiko new app) — produces walkthrough's scaffold files", () => {
|
|
36
|
-
const result = scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
37
|
-
expect(result.files).
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"bin/dev.ts",
|
|
43
|
-
"src/client.tsx",
|
|
44
|
-
".env.example",
|
|
45
|
-
"docker-compose.yml",
|
|
46
|
-
"README.md",
|
|
47
|
-
]);
|
|
35
|
+
test("Step 1 (kumiko new app) — produces walkthrough's scaffold files", async () => {
|
|
36
|
+
const result = await scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
37
|
+
expect(result.files).toContain("package.json");
|
|
38
|
+
expect(result.files).toContain("kumiko/schema.ts");
|
|
39
|
+
expect(result.files).toContain("bin/kumiko.ts");
|
|
40
|
+
expect(result.files).toContain("deploy/Dockerfile");
|
|
41
|
+
expect(result.files).toContain("kumiko/migrations/0001_init.sql");
|
|
48
42
|
});
|
|
49
43
|
|
|
50
|
-
test("Step 2 (kumiko add feature) — auto-mounts + walkthrough diff matches", () => {
|
|
51
|
-
scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
44
|
+
test("Step 2 (kumiko add feature) — auto-mounts + walkthrough diff matches", async () => {
|
|
45
|
+
await scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
52
46
|
const result = scaffoldAppFeature({ name: "notes", appRoot });
|
|
53
47
|
expect(result.autoMounted).toBe(true);
|
|
54
48
|
|
|
@@ -63,8 +57,8 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
63
57
|
expect(runConfig).toContain("createSessionsFeature()");
|
|
64
58
|
});
|
|
65
59
|
|
|
66
|
-
test("Step 3 (boot validation) — scaffolded run-config matches walkthrough's APP_FEATURES claim", () => {
|
|
67
|
-
scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
60
|
+
test("Step 3 (boot validation) — scaffolded run-config matches walkthrough's APP_FEATURES claim", async () => {
|
|
61
|
+
await scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
68
62
|
scaffoldAppFeature({ name: "notes", appRoot });
|
|
69
63
|
|
|
70
64
|
// Text-assert: scaffolded run-config.ts contains exactly the 3 features
|
|
@@ -108,8 +102,8 @@ describe("walkthrough — DX-3.1 snapshot", () => {
|
|
|
108
102
|
expect(registry.features.size).toBe(7);
|
|
109
103
|
});
|
|
110
104
|
|
|
111
|
-
test("bin/main.ts contains the auth.admin stub the walkthrough relies on", () => {
|
|
112
|
-
scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
105
|
+
test("bin/main.ts contains the auth.admin stub the walkthrough relies on", async () => {
|
|
106
|
+
await scaffoldApp({ name: "my-notes", destination: appRoot });
|
|
113
107
|
const main = readFileSync(join(appRoot, "bin/main.ts"), "utf-8");
|
|
114
108
|
// composeFeatures(includeBundled:true)-trigger is `auth: { admin: { … } }`.
|
|
115
109
|
// Walkthrough explicitly says this is what auto-mounts the 4 bundled features.
|
package/src/run-dev-app.ts
CHANGED
|
@@ -22,12 +22,19 @@ import {
|
|
|
22
22
|
buildEnvConfigOverrides,
|
|
23
23
|
createConfigResolver,
|
|
24
24
|
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
25
|
+
import {
|
|
26
|
+
createPatResolver,
|
|
27
|
+
PAT_FEATURE,
|
|
28
|
+
patRateLimitFromFeature,
|
|
29
|
+
patScopesFromFeature,
|
|
30
|
+
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
25
31
|
import {
|
|
26
32
|
createSessionCallbacks,
|
|
27
33
|
type SessionCallbacks,
|
|
28
34
|
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
29
35
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
30
|
-
import type { SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
|
|
36
|
+
import type { PatResolver, SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
|
|
37
|
+
import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
|
|
31
38
|
import {
|
|
32
39
|
collectWriteHandlerQns,
|
|
33
40
|
createRegistry,
|
|
@@ -226,9 +233,8 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
226
233
|
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
227
234
|
});
|
|
228
235
|
|
|
229
|
-
//
|
|
230
|
-
// FILE_STORAGE_PROVIDER
|
|
231
|
-
// nicht über die env-Bridge. Setzen bevor validateBoot greift.
|
|
236
|
+
// An explicitly wired file provider (options.files) satisfies the
|
|
237
|
+
// FILE_STORAGE_PROVIDER boot gate — set it before validateBoot runs.
|
|
232
238
|
if (options.files !== undefined && process.env["FILE_STORAGE_PROVIDER"] === undefined) {
|
|
233
239
|
process.env["FILE_STORAGE_PROVIDER"] = "configured";
|
|
234
240
|
}
|
|
@@ -339,6 +345,26 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
339
345
|
}
|
|
340
346
|
return sessionCallbacks;
|
|
341
347
|
};
|
|
348
|
+
// PAT opt-in: same late-bound holder pattern — the resolver needs the real
|
|
349
|
+
// db (only concrete after setupTestStack). Wired when the feature is mounted;
|
|
350
|
+
// scopes come from the feature's exports (single source with its handlers).
|
|
351
|
+
let patResolver: PatResolver | undefined;
|
|
352
|
+
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
353
|
+
const patAuthFragment = patFeature
|
|
354
|
+
? {
|
|
355
|
+
patResolver: (rawToken: string) => {
|
|
356
|
+
if (!patResolver) {
|
|
357
|
+
throw new Error("[runDevApp] pat-resolver accessed before onAfterSetup");
|
|
358
|
+
}
|
|
359
|
+
return patResolver(rawToken);
|
|
360
|
+
},
|
|
361
|
+
patRateLimiter: (() => {
|
|
362
|
+
const rl = patRateLimitFromFeature(patFeature);
|
|
363
|
+
return createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs);
|
|
364
|
+
})(),
|
|
365
|
+
}
|
|
366
|
+
: {};
|
|
367
|
+
|
|
342
368
|
const sessionAuthFragment =
|
|
343
369
|
effectiveAuth?.sessions !== undefined
|
|
344
370
|
? {
|
|
@@ -392,6 +418,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
392
418
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
393
419
|
}),
|
|
394
420
|
...sessionAuthFragment,
|
|
421
|
+
...patAuthFragment,
|
|
395
422
|
...(effectiveAuth.passwordReset && {
|
|
396
423
|
passwordReset: {
|
|
397
424
|
requestHandler: AuthHandlers.requestPasswordReset,
|
|
@@ -437,6 +464,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
437
464
|
...(expiresInMs !== undefined && { expiresInMs }),
|
|
438
465
|
});
|
|
439
466
|
}
|
|
467
|
+
if (patFeature) {
|
|
468
|
+
patResolver = createPatResolver({ db: stack.db, scopes: patScopesFromFeature(patFeature) });
|
|
469
|
+
}
|
|
440
470
|
if (effectiveAuth) {
|
|
441
471
|
await seedAdmin(stack.db, effectiveAuth.admin);
|
|
442
472
|
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -50,6 +50,12 @@ import {
|
|
|
50
50
|
createDeliveryService,
|
|
51
51
|
DELIVERY_FEATURE,
|
|
52
52
|
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
53
|
+
import {
|
|
54
|
+
createPatResolver,
|
|
55
|
+
PAT_FEATURE,
|
|
56
|
+
patRateLimitFromFeature,
|
|
57
|
+
patScopesFromFeature,
|
|
58
|
+
} from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
|
|
53
59
|
import {
|
|
54
60
|
createSecretsContext,
|
|
55
61
|
SECRETS_FEATURE_NAME,
|
|
@@ -66,6 +72,7 @@ import {
|
|
|
66
72
|
cachedResponse,
|
|
67
73
|
computeStrongEtag,
|
|
68
74
|
computeWeakEtag,
|
|
75
|
+
createInMemoryLoginRateLimiter,
|
|
69
76
|
createSseBroker,
|
|
70
77
|
type SseBroker,
|
|
71
78
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
@@ -84,7 +91,6 @@ import {
|
|
|
84
91
|
findTierResolverUsage,
|
|
85
92
|
type NotifyFactory,
|
|
86
93
|
type Registry,
|
|
87
|
-
type TenantId,
|
|
88
94
|
type TierResolverPlugin,
|
|
89
95
|
validateAppCustomScreenWriteQns,
|
|
90
96
|
validateBoot,
|
|
@@ -170,10 +176,11 @@ export function requireEnv(
|
|
|
170
176
|
): string {
|
|
171
177
|
const value = src[name];
|
|
172
178
|
if (value === undefined || value === "") {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
const advice =
|
|
180
|
+
context === "runProdApp"
|
|
181
|
+
? "Set it in your container env / .env.production / Coolify secrets."
|
|
182
|
+
: "Set it in your .env / shell before running the dev server.";
|
|
183
|
+
throw new Error(`${context}: required env var "${name}" is missing or empty. ${advice}`);
|
|
177
184
|
}
|
|
178
185
|
return value;
|
|
179
186
|
}
|
|
@@ -523,7 +530,7 @@ export type RunProdAppOptions = {
|
|
|
523
530
|
* liche features aktiv via globalFeatureToggleRuntime. Pattern:
|
|
524
531
|
* createLateBoundHolder + post-boot runtime.initialize in einem
|
|
525
532
|
* seed-fn (db ist erst nach migrations + features ready). */
|
|
526
|
-
readonly effectiveFeatures?:
|
|
533
|
+
readonly effectiveFeatures?: EffectiveFeaturesResolver;
|
|
527
534
|
/** Composed Zod-schema for env-validation (from `composeEnvSchema({
|
|
528
535
|
* features, extend })` in @cosmicdrift/kumiko-framework/env). When set:
|
|
529
536
|
* - `process.env` is parsed against it BEFORE any boot work; missing
|
|
@@ -570,14 +577,7 @@ export type ProdAppHandle = {
|
|
|
570
577
|
readonly stop: () => Promise<void>;
|
|
571
578
|
};
|
|
572
579
|
|
|
573
|
-
//
|
|
574
|
-
// ConfigAccessor when `_configAccessorFactory` is on the AppContext
|
|
575
|
-
// (pipeline/dispatcher.ts). Without it `ctx.config` stays undefined and any
|
|
576
|
-
// handler reading it — e.g. createFileProviderForTenant for the GDPR export
|
|
577
|
-
// download — throws "ctx.config is missing". Built from the EFFECTIVE resolver
|
|
578
|
-
// so an app-supplied configResolver override (its appOverrides) is the one
|
|
579
|
-
// ctx.config reads. Shared with runDevApp (mergeConfigResolverDefault) for
|
|
580
|
-
// dev/prod parity.
|
|
580
|
+
// Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
|
|
581
581
|
export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
|
|
582
582
|
resolved: T,
|
|
583
583
|
registry: Registry,
|
|
@@ -957,6 +957,24 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
957
957
|
? buildProdSessionAuth(db, resolveProdSessionsConfig(effectiveAuth?.sessions))
|
|
958
958
|
: undefined;
|
|
959
959
|
|
|
960
|
+
// PAT opt-in: if the personal-access-tokens feature is mounted, wire its
|
|
961
|
+
// resolver (bearer PATs → SessionUser, before jwt.verify). Scopes come from
|
|
962
|
+
// the feature's exports — the same declaration its handlers use.
|
|
963
|
+
const patFeature = features.find((f) => f.name === PAT_FEATURE);
|
|
964
|
+
let patAuthFragment:
|
|
965
|
+
| {
|
|
966
|
+
patResolver: ReturnType<typeof createPatResolver>;
|
|
967
|
+
patRateLimiter: ReturnType<typeof createInMemoryLoginRateLimiter>;
|
|
968
|
+
}
|
|
969
|
+
| undefined;
|
|
970
|
+
if (effectiveAuth && patFeature) {
|
|
971
|
+
const rl = patRateLimitFromFeature(patFeature);
|
|
972
|
+
patAuthFragment = {
|
|
973
|
+
patResolver: createPatResolver({ db, scopes: patScopesFromFeature(patFeature) }),
|
|
974
|
+
patRateLimiter: createInMemoryLoginRateLimiter(rl.maxRequests, rl.windowMs),
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
960
978
|
const baseEntrypointOptions = {
|
|
961
979
|
registry,
|
|
962
980
|
context: {
|
|
@@ -994,6 +1012,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
994
1012
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
995
1013
|
}),
|
|
996
1014
|
...sessionAuthFragment,
|
|
1015
|
+
...patAuthFragment,
|
|
997
1016
|
...(effectiveAuth.passwordReset && {
|
|
998
1017
|
passwordReset: {
|
|
999
1018
|
requestHandler: AuthHandlers.requestPasswordReset,
|
package/src/scaffold-app.ts
CHANGED
|
@@ -12,9 +12,17 @@
|
|
|
12
12
|
// Static files (package.json, tsconfig, .env, README) stay text-based.
|
|
13
13
|
|
|
14
14
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { join, resolve } from "node:path";
|
|
15
|
+
import { join, relative, resolve } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
collectTableMetas,
|
|
18
|
+
generateMigration,
|
|
19
|
+
writeSnapshotJson,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
22
|
import { IndentationText, Project, VariableDeclarationKind } from "ts-morph";
|
|
23
|
+
import { composeFeatures } from "./compose-features";
|
|
17
24
|
import { isKebabSegment } from "./kebab";
|
|
25
|
+
import { scaffoldDeploy } from "./scaffold-deploy";
|
|
18
26
|
|
|
19
27
|
// Single bundled-feature entry the scaffolder mounts into run-config.ts.
|
|
20
28
|
// importPath is the from-spec ("@cosmicdrift/kumiko-bundled-features/files"),
|
|
@@ -52,7 +60,7 @@ export type ScaffoldAppResult = {
|
|
|
52
60
|
readonly appName: string;
|
|
53
61
|
};
|
|
54
62
|
|
|
55
|
-
export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
|
|
63
|
+
export async function scaffoldApp(options: ScaffoldAppOptions): Promise<ScaffoldAppResult> {
|
|
56
64
|
if (!isKebabSegment(options.name)) {
|
|
57
65
|
throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
|
|
58
66
|
}
|
|
@@ -65,6 +73,7 @@ export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
|
|
|
65
73
|
|
|
66
74
|
mkdirSync(join(destination, "bin"), { recursive: true });
|
|
67
75
|
mkdirSync(join(destination, "src"), { recursive: true });
|
|
76
|
+
mkdirSync(join(destination, "kumiko"), { recursive: true });
|
|
68
77
|
|
|
69
78
|
const files: string[] = [];
|
|
70
79
|
|
|
@@ -74,24 +83,52 @@ export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
|
|
|
74
83
|
write(join(destination, "tsconfig.json"), renderTsconfig());
|
|
75
84
|
files.push("tsconfig.json");
|
|
76
85
|
|
|
86
|
+
write(join(destination, "biome.json"), renderBiomeJson());
|
|
87
|
+
files.push("biome.json");
|
|
88
|
+
|
|
89
|
+
write(join(destination, "bunfig.toml"), renderBunfigToml());
|
|
90
|
+
files.push("bunfig.toml");
|
|
91
|
+
|
|
92
|
+
write(join(destination, "bunfig.ci.toml"), renderBunfigCiToml());
|
|
93
|
+
files.push("bunfig.ci.toml");
|
|
94
|
+
|
|
77
95
|
write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features));
|
|
78
96
|
files.push("src/run-config.ts");
|
|
79
97
|
|
|
98
|
+
write(join(destination, "kumiko", "schema.ts"), renderKumikoSchema());
|
|
99
|
+
files.push("kumiko/schema.ts");
|
|
100
|
+
|
|
80
101
|
write(join(destination, "bin", "main.ts"), renderMain(options.name));
|
|
81
102
|
files.push("bin/main.ts");
|
|
82
103
|
|
|
83
104
|
write(join(destination, "bin", "dev.ts"), renderDev(options.name));
|
|
84
105
|
files.push("bin/dev.ts");
|
|
85
106
|
|
|
107
|
+
write(join(destination, "bin", "kumiko.ts"), renderBinKumiko());
|
|
108
|
+
files.push("bin/kumiko.ts");
|
|
109
|
+
|
|
86
110
|
write(join(destination, "src", "client.tsx"), renderClient());
|
|
87
111
|
files.push("src/client.tsx");
|
|
88
112
|
|
|
113
|
+
write(join(destination, "src", "styles.css"), renderStylesCss());
|
|
114
|
+
files.push("src/styles.css");
|
|
115
|
+
|
|
89
116
|
write(join(destination, ".env.example"), renderEnvExample(options.name));
|
|
90
117
|
files.push(".env.example");
|
|
91
118
|
|
|
92
119
|
write(join(destination, "docker-compose.yml"), renderDockerCompose());
|
|
93
120
|
files.push("docker-compose.yml");
|
|
94
121
|
|
|
122
|
+
await writeInitMigration(destination, options.features);
|
|
123
|
+
files.push("kumiko/migrations/0001_init.sql", "kumiko/migrations/.snapshot.json");
|
|
124
|
+
|
|
125
|
+
const deploy = scaffoldDeploy({ appName: options.name, destination });
|
|
126
|
+
for (const f of deploy.files) {
|
|
127
|
+
if (f.written) {
|
|
128
|
+
files.push(relative(destination, f.path));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
95
132
|
write(join(destination, "README.md"), renderReadme(options.name, options.features));
|
|
96
133
|
files.push("README.md");
|
|
97
134
|
|
|
@@ -112,16 +149,31 @@ function renderPackageJson(name: string, version: string): string {
|
|
|
112
149
|
type: "module",
|
|
113
150
|
scripts: {
|
|
114
151
|
dev: "bun --watch bin/dev.ts",
|
|
152
|
+
build: "bun kumiko-build",
|
|
153
|
+
start: "bun run bin/main.ts",
|
|
115
154
|
boot: "KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts",
|
|
116
|
-
|
|
155
|
+
typecheck: "tsc --noEmit",
|
|
156
|
+
lint: "biome check .",
|
|
157
|
+
test: "bun --config=bunfig.ci.toml test --dots",
|
|
158
|
+
"schema:apply": "bun kumiko-schema apply",
|
|
159
|
+
"schema:generate": "bun kumiko-schema generate",
|
|
117
160
|
},
|
|
118
161
|
dependencies: {
|
|
119
162
|
"@cosmicdrift/kumiko-bundled-features": version,
|
|
120
163
|
"@cosmicdrift/kumiko-dev-server": version,
|
|
121
164
|
"@cosmicdrift/kumiko-framework": version,
|
|
122
165
|
"@cosmicdrift/kumiko-renderer-web": version,
|
|
166
|
+
react: "^19.2.6",
|
|
167
|
+
"react-dom": "^19.2.6",
|
|
123
168
|
zod: "^4.4.3",
|
|
124
169
|
},
|
|
170
|
+
devDependencies: {
|
|
171
|
+
"@biomejs/biome": "^2.4.15",
|
|
172
|
+
"@tailwindcss/cli": "^4.3.0",
|
|
173
|
+
"bun-types": "^1.3.14",
|
|
174
|
+
tailwindcss: "^4.3.0",
|
|
175
|
+
typescript: "^6.0.3",
|
|
176
|
+
},
|
|
125
177
|
},
|
|
126
178
|
null,
|
|
127
179
|
2,
|
|
@@ -141,17 +193,107 @@ function renderTsconfig(): string {
|
|
|
141
193
|
moduleResolution: "bundler",
|
|
142
194
|
esModuleInterop: true,
|
|
143
195
|
skipLibCheck: true,
|
|
144
|
-
lib: ["ESNext"],
|
|
196
|
+
lib: ["ESNext", "DOM"],
|
|
145
197
|
types: ["bun-types"],
|
|
198
|
+
jsx: "react-jsx",
|
|
146
199
|
noEmit: true,
|
|
147
200
|
},
|
|
148
|
-
include: ["bin", "src"],
|
|
201
|
+
include: ["bin", "src", "kumiko"],
|
|
149
202
|
},
|
|
150
203
|
null,
|
|
151
204
|
2,
|
|
152
205
|
)}\n`;
|
|
153
206
|
}
|
|
154
207
|
|
|
208
|
+
function renderBiomeJson(): string {
|
|
209
|
+
return `${JSON.stringify(
|
|
210
|
+
{
|
|
211
|
+
$schema: "https://biomejs.dev/schemas/2.4.15/schema.json",
|
|
212
|
+
vcs: {
|
|
213
|
+
enabled: true,
|
|
214
|
+
clientKind: "git",
|
|
215
|
+
useIgnoreFile: true,
|
|
216
|
+
defaultBranch: "main",
|
|
217
|
+
},
|
|
218
|
+
files: {
|
|
219
|
+
includes: ["src/**", "bin/**", "kumiko/**", "!**/dist", "!kumiko/migrations"],
|
|
220
|
+
},
|
|
221
|
+
formatter: {
|
|
222
|
+
enabled: true,
|
|
223
|
+
indentStyle: "space",
|
|
224
|
+
indentWidth: 2,
|
|
225
|
+
lineWidth: 100,
|
|
226
|
+
lineEnding: "lf",
|
|
227
|
+
},
|
|
228
|
+
css: {
|
|
229
|
+
parser: { cssModules: false, tailwindDirectives: true },
|
|
230
|
+
},
|
|
231
|
+
javascript: {
|
|
232
|
+
formatter: {
|
|
233
|
+
quoteStyle: "double",
|
|
234
|
+
jsxQuoteStyle: "double",
|
|
235
|
+
semicolons: "always",
|
|
236
|
+
trailingCommas: "all",
|
|
237
|
+
arrowParentheses: "always",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
json: { formatter: { indentWidth: 2, lineWidth: 80 } },
|
|
241
|
+
linter: {
|
|
242
|
+
enabled: true,
|
|
243
|
+
rules: {
|
|
244
|
+
recommended: true,
|
|
245
|
+
correctness: { noUnusedVariables: "error", noUnusedImports: "error" },
|
|
246
|
+
suspicious: { noExplicitAny: "error", noDebugger: "error", noConsole: "warn" },
|
|
247
|
+
complexity: { useLiteralKeys: "off" },
|
|
248
|
+
style: { useConst: "error" },
|
|
249
|
+
nursery: { noFloatingPromises: "error" },
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
overrides: [
|
|
253
|
+
{
|
|
254
|
+
includes: ["**/*.test.ts", "**/*.spec.ts", "**/*.integration.ts", "**/*.e2e.ts"],
|
|
255
|
+
linter: {
|
|
256
|
+
rules: {
|
|
257
|
+
suspicious: { noConsole: "off" },
|
|
258
|
+
style: { noNonNullAssertion: "off" },
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
},
|
|
264
|
+
null,
|
|
265
|
+
2,
|
|
266
|
+
)}\n`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderBunfigToml(): string {
|
|
270
|
+
return `[install]
|
|
271
|
+
linker = "hoisted"
|
|
272
|
+
|
|
273
|
+
[test]
|
|
274
|
+
concurrency = 8
|
|
275
|
+
pathIgnorePatterns = [
|
|
276
|
+
"**/e2e/**",
|
|
277
|
+
"**/*.spec.ts",
|
|
278
|
+
"**/dist/**",
|
|
279
|
+
]
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function renderBunfigCiToml(): string {
|
|
284
|
+
return `[install]
|
|
285
|
+
linker = "hoisted"
|
|
286
|
+
|
|
287
|
+
[test]
|
|
288
|
+
concurrency = 8
|
|
289
|
+
pathIgnorePatterns = [
|
|
290
|
+
"**/e2e/**",
|
|
291
|
+
"**/*.spec.ts",
|
|
292
|
+
"**/dist/**",
|
|
293
|
+
]
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
|
|
155
297
|
function newTsProject(): Project {
|
|
156
298
|
return new Project({
|
|
157
299
|
useInMemoryFileSystem: true,
|
|
@@ -187,8 +329,13 @@ function renderRunConfig(features?: ReadonlyArray<ScaffoldFeatureEntry>): string
|
|
|
187
329
|
const project = newTsProject();
|
|
188
330
|
const sf = project.createSourceFile("run-config.ts", "");
|
|
189
331
|
|
|
190
|
-
|
|
191
|
-
|
|
332
|
+
// Fallback decided BEFORE filtering: if the caller passed features (even
|
|
333
|
+
// if every one of them is an auto-mounted name), an all-filtered-out empty
|
|
334
|
+
// result is the correct outcome — falling back to FOUNDATION_FEATURES here
|
|
335
|
+
// would silently substitute a different feature set than the caller asked
|
|
336
|
+
// for.
|
|
337
|
+
const base = features?.length ? features : FOUNDATION_FEATURES;
|
|
338
|
+
const effective = base.filter((f) => !COMPOSE_AUTO_MOUNTED_NAMES.has(f.name));
|
|
192
339
|
const grouped = new Map<string, string[]>();
|
|
193
340
|
for (const entry of effective) {
|
|
194
341
|
const existing = grouped.get(entry.importPath) ?? [];
|
|
@@ -211,6 +358,12 @@ function renderRunConfig(features?: ReadonlyArray<ScaffoldFeatureEntry>): string
|
|
|
211
358
|
],
|
|
212
359
|
});
|
|
213
360
|
|
|
361
|
+
sf.addVariableStatement({
|
|
362
|
+
declarationKind: VariableDeclarationKind.Const,
|
|
363
|
+
isExported: true,
|
|
364
|
+
declarations: [{ name: "HAS_AUTH", initializer: "true" }],
|
|
365
|
+
});
|
|
366
|
+
|
|
214
367
|
sf.insertText(
|
|
215
368
|
0,
|
|
216
369
|
[
|
|
@@ -250,7 +403,7 @@ function renderMain(appName: string): string {
|
|
|
250
403
|
});
|
|
251
404
|
sf.addImportDeclaration({
|
|
252
405
|
moduleSpecifier: "../src/run-config",
|
|
253
|
-
namedImports: ["APP_FEATURES"],
|
|
406
|
+
namedImports: ["APP_FEATURES", "HAS_AUTH"],
|
|
254
407
|
});
|
|
255
408
|
|
|
256
409
|
sf.addVariableStatement({
|
|
@@ -265,7 +418,7 @@ function renderMain(appName: string): string {
|
|
|
265
418
|
|
|
266
419
|
// The envSchema must cover the SAME features runProdApp mounts at boot.
|
|
267
420
|
// `auth: { admin: … }` below makes runProdApp auto-mix config/user/tenant/
|
|
268
|
-
// auth-email-password via composeFeatures(includeBundled:
|
|
421
|
+
// auth-email-password via composeFeatures(includeBundled:HAS_AUTH); compose the
|
|
269
422
|
// identical set here so the auth feature's JWT_SECRET (min-32) declaration
|
|
270
423
|
// is part of the boot-gate — otherwise a too-short JWT_SECRET slips through.
|
|
271
424
|
sf.addVariableStatement({
|
|
@@ -273,7 +426,7 @@ function renderMain(appName: string): string {
|
|
|
273
426
|
declarations: [
|
|
274
427
|
{
|
|
275
428
|
name: "bootFeatures",
|
|
276
|
-
initializer: "composeFeatures(APP_FEATURES, { includeBundled:
|
|
429
|
+
initializer: "composeFeatures(APP_FEATURES, { includeBundled: HAS_AUTH })",
|
|
277
430
|
},
|
|
278
431
|
],
|
|
279
432
|
});
|
|
@@ -294,7 +447,7 @@ function renderMain(appName: string): string {
|
|
|
294
447
|
.inlineBlock(() => {
|
|
295
448
|
writer.writeLine("features: APP_FEATURES,");
|
|
296
449
|
writer.writeLine("envSchema,");
|
|
297
|
-
writer.writeLine(
|
|
450
|
+
writer.writeLine('staticDir: "./dist",');
|
|
298
451
|
writer.write("auth: ").inlineBlock(() => {
|
|
299
452
|
writer.write("admin: ").inlineBlock(() => {
|
|
300
453
|
writer.writeLine(`email: "admin@${appName}.local",`);
|
|
@@ -458,19 +611,20 @@ KUMIKO_DEV_DB_NAME=${devDb}
|
|
|
458
611
|
`;
|
|
459
612
|
}
|
|
460
613
|
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
// Redis must not be reachable from the LAN on a machine without a firewall.
|
|
614
|
+
// Ports + credentials match the *_URL defaults in renderEnvExample, so
|
|
615
|
+
// `docker compose up -d` just works with the generated .env. Named pg volume
|
|
616
|
+
// so dev data survives `docker compose down` (pairs with KUMIKO_DEV_DB_NAME
|
|
617
|
+
// persistence) — the loopback-binding rationale is in the generated file's
|
|
618
|
+
// own comment (657/1), no need to duplicate it here.
|
|
467
619
|
function renderDockerCompose(): string {
|
|
468
620
|
return `# Local Postgres + Redis for \`bun dev\`. Matches the *_URL defaults in .env.example.
|
|
469
621
|
# Start: docker compose up -d · Stop: docker compose down · Reset: docker compose down -v
|
|
470
622
|
# Ports bind to 127.0.0.1 only — weak dev credentials must not be exposed on the LAN.
|
|
471
623
|
services:
|
|
472
624
|
postgres:
|
|
473
|
-
|
|
625
|
+
# Pinned to the project's own compose-file tag (663/1) — Alpine variant
|
|
626
|
+
# (~90MB vs ~400MB) and a reproducible patch version, bump on PG18 minors.
|
|
627
|
+
image: postgres:18.3-alpine
|
|
474
628
|
environment:
|
|
475
629
|
POSTGRES_USER: postgres
|
|
476
630
|
POSTGRES_PASSWORD: postgres
|
|
@@ -530,17 +684,140 @@ bun run boot
|
|
|
530
684
|
Runs \`KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts\` — validates feature composition
|
|
531
685
|
+ env schema, exits 0 without touching DB/Redis. Useful in CI.
|
|
532
686
|
|
|
687
|
+
## Production build + schema
|
|
688
|
+
|
|
689
|
+
\`\`\`sh
|
|
690
|
+
bun run build # kumiko-build → dist/ + dist-server/
|
|
691
|
+
bun run schema:apply # apply checked-in kumiko/migrations (needs DATABASE_URL)
|
|
692
|
+
bun run start # runProdApp against dist/
|
|
693
|
+
\`\`\`
|
|
694
|
+
|
|
695
|
+
After adding entities/features, regenerate migrations:
|
|
696
|
+
|
|
697
|
+
\`\`\`sh
|
|
698
|
+
bun run schema:generate <name>
|
|
699
|
+
\`\`\`
|
|
700
|
+
|
|
701
|
+
## Deploy
|
|
702
|
+
|
|
703
|
+
\`deploy/Dockerfile\` + \`deploy/migrate-step.sh\` are scaffolded for container
|
|
704
|
+
deploys. Build context = app repo root; migrations ship in \`kumiko/migrations/\`.
|
|
705
|
+
|
|
533
706
|
## Architecture
|
|
534
707
|
|
|
535
|
-
- \`src/run-config.ts\` — single source of truth: which features your app mounts.
|
|
708
|
+
- \`src/run-config.ts\` — single source of truth: which features your app mounts (\`APP_FEATURES\`, \`HAS_AUTH\`).
|
|
709
|
+
- \`kumiko/schema.ts\` — same feature set → \`ENTITY_METAS\` for \`kumiko schema\`.
|
|
536
710
|
- \`bin/dev.ts\` — dev-server entry (\`bun dev\`).
|
|
537
|
-
- \`bin/main.ts\` — production-bootstrap (\`bun run
|
|
711
|
+
- \`bin/main.ts\` — production-bootstrap (\`bun run start\`).
|
|
712
|
+
- \`bin/kumiko.ts\` — schema-CLI bundled into \`dist-server/kumiko.js\`.
|
|
538
713
|
- \`docker-compose.yml\` — local Postgres + Redis for \`bun dev\`.
|
|
539
714
|
|
|
540
715
|
For full docs see https://docs.kumiko.rocks.
|
|
541
716
|
`;
|
|
542
717
|
}
|
|
543
718
|
|
|
719
|
+
function renderStylesCss(): string {
|
|
720
|
+
return [
|
|
721
|
+
'@import "@cosmicdrift/kumiko-renderer-web/styles.css";',
|
|
722
|
+
"",
|
|
723
|
+
'@source "./**/*.{ts,tsx}";',
|
|
724
|
+
"",
|
|
725
|
+
].join("\n");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function renderKumikoSchema(): string {
|
|
729
|
+
return [
|
|
730
|
+
"// Live ENTITY_METAS source for `kumiko schema generate|apply|status`.",
|
|
731
|
+
"//",
|
|
732
|
+
"// Computes table-metas from the SAME composeFeatures(APP_FEATURES) the",
|
|
733
|
+
"// runtime sees (runProdApp/runDevApp) — migration and runtime cannot drift.",
|
|
734
|
+
"",
|
|
735
|
+
'import { composeFeatures } from "@cosmicdrift/kumiko-dev-server/compose-features";',
|
|
736
|
+
'import { collectTableMetas, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";',
|
|
737
|
+
'import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";',
|
|
738
|
+
'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
|
|
739
|
+
"",
|
|
740
|
+
"export const FEATURES: readonly FeatureDefinition[] = composeFeatures([...APP_FEATURES], {",
|
|
741
|
+
" includeBundled: HAS_AUTH,",
|
|
742
|
+
"});",
|
|
743
|
+
"",
|
|
744
|
+
"export const ENTITY_METAS: readonly EntityTableMeta[] = collectTableMetas(FEATURES);",
|
|
745
|
+
"",
|
|
746
|
+
].join("\n");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function renderBinKumiko(): string {
|
|
750
|
+
return [
|
|
751
|
+
"#!/usr/bin/env bun",
|
|
752
|
+
"",
|
|
753
|
+
"// Standalone kumiko schema-CLI for the production bundle. The deploy",
|
|
754
|
+
"// migrate-step runs `bun /app/kumiko.js schema apply`; kumiko-build bundles",
|
|
755
|
+
"// this file to dist-server/kumiko.js.",
|
|
756
|
+
"",
|
|
757
|
+
'import { composeFeatures } from "@cosmicdrift/kumiko-dev-server/compose-features";',
|
|
758
|
+
'import { runSchemaCli } from "@cosmicdrift/kumiko-framework/schema-cli";',
|
|
759
|
+
'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
|
|
760
|
+
"",
|
|
761
|
+
"const [, , cmd, ...rest] = Bun.argv;",
|
|
762
|
+
'if (cmd !== "schema") {',
|
|
763
|
+
" // biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
|
|
764
|
+
' console.error("\\n Unbekannt: kumiko " + (cmd ?? "") + " — nur \'kumiko schema <sub>\' im Standalone-Bundle.\\n");',
|
|
765
|
+
" process.exit(1);",
|
|
766
|
+
"}",
|
|
767
|
+
"",
|
|
768
|
+
"const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
|
|
769
|
+
"// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
|
|
770
|
+
"const out = { log: (l: string) => console.log(l), err: (l: string) => console.error(l) };",
|
|
771
|
+
"process.exit(await runSchemaCli(rest, process.env.INIT_CWD ?? process.cwd(), out, { features }));",
|
|
772
|
+
"",
|
|
773
|
+
].join("\n");
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async function instantiateScaffoldFeatures(
|
|
777
|
+
features?: ReadonlyArray<ScaffoldFeatureEntry>,
|
|
778
|
+
): Promise<readonly FeatureDefinition[]> {
|
|
779
|
+
const base = features?.length ? features : FOUNDATION_FEATURES;
|
|
780
|
+
const effective = base.filter((f) => !COMPOSE_AUTO_MOUNTED_NAMES.has(f.name));
|
|
781
|
+
const instances: FeatureDefinition[] = [];
|
|
782
|
+
for (const entry of effective) {
|
|
783
|
+
const mod = (await import(entry.importPath)) as Record<string, unknown>;
|
|
784
|
+
const exp = mod[entry.exportName];
|
|
785
|
+
if (exp === undefined) {
|
|
786
|
+
throw new Error(
|
|
787
|
+
`scaffoldApp: ${entry.importPath} missing export ${entry.exportName} for ${entry.callExpression}`,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
if (entry.callExpression.endsWith("()")) {
|
|
791
|
+
if (typeof exp !== "function") {
|
|
792
|
+
throw new Error(`scaffoldApp: ${entry.exportName} is not callable (${entry.importPath})`);
|
|
793
|
+
}
|
|
794
|
+
instances.push((exp as () => FeatureDefinition)());
|
|
795
|
+
} else {
|
|
796
|
+
instances.push(exp as FeatureDefinition);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return instances;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async function writeInitMigration(
|
|
803
|
+
destination: string,
|
|
804
|
+
features?: ReadonlyArray<ScaffoldFeatureEntry>,
|
|
805
|
+
): Promise<void> {
|
|
806
|
+
const instances = await instantiateScaffoldFeatures(features);
|
|
807
|
+
const composed = composeFeatures(instances, { includeBundled: true });
|
|
808
|
+
const metas = collectTableMetas(composed);
|
|
809
|
+
const result = generateMigration({
|
|
810
|
+
metas,
|
|
811
|
+
prevSnapshot: null,
|
|
812
|
+
name: "init",
|
|
813
|
+
sequenceNumber: 1,
|
|
814
|
+
});
|
|
815
|
+
const migrationsDir = join(destination, "kumiko", "migrations");
|
|
816
|
+
mkdirSync(migrationsDir, { recursive: true });
|
|
817
|
+
writeFileSync(join(migrationsDir, result.filename), result.sqlContent);
|
|
818
|
+
writeSnapshotJson(join(migrationsDir, ".snapshot.json"), result.snapshot);
|
|
819
|
+
}
|
|
820
|
+
|
|
544
821
|
// Deterministic tenant-ID from app-name. Format: UUID-v4 with the
|
|
545
822
|
// version-marker at the right spot. NOT cryptographically random —
|
|
546
823
|
// just a stable per-app default the user can change later.
|