@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.105.2",
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.105.2",
54
- "@cosmicdrift/kumiko-framework": "0.105.2",
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 deleteMany(suite.stack.db, userTable, {});
188
- await deleteMany(suite.stack.db, tenantMembershipsTable, {});
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 deleteMany(suite.stack.db, userTable, {});
261
- await deleteMany(suite.stack.db, tenantMembershipsTable, {});
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 deleteMany(suite.stack.db, userTable, {});
306
- await deleteMany(suite.stack.db, tenantMembershipsTable, {});
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 deleteMany(suite.stack.db, userTable, {});
359
- await deleteMany(suite.stack.db, tenantMembershipsTable, {});
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
- expect(result.files).toEqual([
25
- "package.json",
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("bin/dev.ts contains runDevApp + welcomeBanner + admin login + clientEntry", () => {
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
- // envSchema must cover the same features runProdApp auto-mixes via
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 as foundation", () => {
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(() => scaffoldApp({ name: "MyShop", destination: tmp })).toThrow(/kebab-case/);
196
- expect(() => scaffoldApp({ name: "my_shop", destination: tmp })).toThrow(/kebab-case/);
197
- expect(() => scaffoldApp({ name: "0shop", destination: tmp })).toThrow(/kebab-case/);
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(() => scaffoldApp({ name: "my-shop-", destination: tmp })).toThrow(/kebab-case/);
202
- expect(() => scaffoldApp({ name: "my--shop", destination: tmp })).toThrow(/kebab-case/);
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
- // The CLI passes ctx.cwd; the scaffold must land under it so the
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(() => scaffoldApp({ name: "existing", destination: dest })).toThrow(/already exists/);
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 (config/user/tenant/auth-email-password) are filtered out — no boot-time dedupe-warn spam", () => {
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("deterministic tenantId for same name (reproducible boots)", () => {
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).toEqual([
38
- "package.json",
39
- "tsconfig.json",
40
- "src/run-config.ts",
41
- "bin/main.ts",
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.
@@ -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
- // Ein explizit gewireter File-Provider (options.files) erfüllt das
230
- // FILE_STORAGE_PROVIDER-Boot-Gateder Provider IST konfiguriert, nur
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
  }
@@ -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
- throw new Error(
174
- `${context}: required env var "${name}" is missing or empty. ` +
175
- `Set it in your container env / .env.production / Coolify secrets.`,
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?: (tenantId: TenantId) => ReadonlySet<string>;
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
- // Mint `ctx.config` per request: the dispatcher only builds a per-user
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,
@@ -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
- check: "tsc --noEmit",
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
- const filtered = (features ?? []).filter((f) => !COMPOSE_AUTO_MOUNTED_NAMES.has(f.name));
191
- const effective = filtered.length > 0 ? filtered : FOUNDATION_FEATURES;
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:true); compose the
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: true })",
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("migrations: false,");
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
- // Local Postgres + Redis for `bun dev`. Ports + credentials match the *_URL
462
- // defaults in renderEnvExample, so `docker compose up -d` (referenced by the
463
- // README) just works with the generated .env. Named pg volume so dev data
464
- // survives `docker compose down` (pairs with KUMIKO_DEV_DB_NAME persistence).
465
- // Ports bind to 127.0.0.1 only: the dev DB (postgres/postgres) and auth-less
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
- image: postgres:18
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 boot\` smoke + production deploy).
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.