@cosmicdrift/kumiko-dev-server 1.0.0 → 2.0.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.
Files changed (66) hide show
  1. package/bin/kumiko-schema-check.ts +7 -0
  2. package/package.json +12 -7
  3. package/src/__tests__/build-prod-bundle.integration.test.ts +1 -1
  4. package/src/__tests__/build-server-bundle.test.ts +62 -0
  5. package/src/__tests__/compose-stacks.test.ts +271 -0
  6. package/src/__tests__/create-kumiko-server-errors.test.ts +54 -0
  7. package/src/__tests__/create-kumiko-server-options.test.ts +33 -0
  8. package/src/__tests__/create-kumiko-server.integration.test.ts +128 -1
  9. package/src/__tests__/discover-format.test.ts +1 -1
  10. package/src/__tests__/env-schema.integration.test.ts +1 -1
  11. package/src/__tests__/few-shot-corpus.test.ts +16 -11
  12. package/src/__tests__/merge-extra-context.test.ts +56 -0
  13. package/src/__tests__/resolve-stylesheet.test.ts +16 -0
  14. package/src/__tests__/saas-identity-wire.integration.test.ts +430 -0
  15. package/src/__tests__/scaffold-app-feature.test.ts +59 -6
  16. package/src/__tests__/scaffold-app.test.ts +34 -2
  17. package/src/__tests__/schema-apply.integration.test.ts +5 -2
  18. package/src/__tests__/setup-test-stack-from-features.integration.test.ts +30 -0
  19. package/src/__tests__/walkthrough.integration.test.ts +22 -6
  20. package/src/build-server-bundle.ts +33 -15
  21. package/src/build.ts +1 -2
  22. package/src/codegen/__tests__/render-codegen.test.ts +2 -1
  23. package/src/codegen/__tests__/run-codegen.test.ts +2 -2
  24. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +6 -1
  25. package/src/codegen/__tests__/watch.test.ts +1 -2
  26. package/src/codegen/render.ts +6 -1
  27. package/src/codegen/scan-events.ts +7 -5
  28. package/src/codegen/watch.ts +7 -4
  29. package/src/compose-stacks.ts +184 -0
  30. package/src/create-kumiko-server.ts +28 -5
  31. package/src/few-shot-corpus.ts +4 -3
  32. package/src/index.ts +30 -12
  33. package/src/run-dev-app.ts +195 -61
  34. package/src/scaffold-app-feature.ts +118 -15
  35. package/src/scaffold-app.ts +151 -79
  36. package/src/scaffold-demo-tasks.ts +233 -0
  37. package/src/schema-apply.ts +15 -2
  38. package/src/schema-check-core.ts +1 -1
  39. package/src/setup-test-stack-from-features.ts +61 -0
  40. package/src/welcome-banner.ts +1 -4
  41. package/src/__tests__/boot-extra-context.test.ts +0 -140
  42. package/src/__tests__/build-prod-bundle.test.ts +0 -311
  43. package/src/__tests__/cache-headers.test.ts +0 -83
  44. package/src/__tests__/compose-features-wiring.integration.test.ts +0 -382
  45. package/src/__tests__/compose-features.test.ts +0 -129
  46. package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
  47. package/src/__tests__/inject-schema.test.ts +0 -62
  48. package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
  49. package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
  50. package/src/__tests__/require-env.test.ts +0 -29
  51. package/src/__tests__/resolve-auth-mail.test.ts +0 -69
  52. package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
  53. package/src/__tests__/run-prod-app-env-source.test.ts +0 -157
  54. package/src/__tests__/run-prod-app-spec.test.ts +0 -57
  55. package/src/__tests__/run-prod-app.integration.test.ts +0 -840
  56. package/src/__tests__/session-wiring.test.ts +0 -51
  57. package/src/__tests__/try-hono-first.test.ts +0 -63
  58. package/src/boot/apply-boot-seeds.ts +0 -18
  59. package/src/build-prod-bundle.ts +0 -697
  60. package/src/compose-features.ts +0 -145
  61. package/src/extra-routes-deps.ts +0 -47
  62. package/src/inject-schema.ts +0 -24
  63. package/src/resolve-tailwind-cli.ts +0 -45
  64. package/src/run-prod-app.ts +0 -1492
  65. package/src/session-wiring.ts +0 -29
  66. package/src/try-hono-first.ts +0 -46
@@ -68,6 +68,7 @@ async function readMountedFeatures(runConfigPath: string): Promise<Set<string>>
68
68
  const mod = (await import(runConfigPath)) as {
69
69
  APP_FEATURES?: ReadonlyArray<{ name: string }>;
70
70
  HAS_AUTH?: boolean;
71
+ HAS_SIGNUP?: boolean;
71
72
  };
72
73
  if (!mod.APP_FEATURES) {
73
74
  throw new Error(
@@ -85,6 +86,12 @@ async function readMountedFeatures(runConfigPath: string): Promise<Set<string>>
85
86
  if (mod.HAS_AUTH ?? true) {
86
87
  for (const name of implicitAuthModeFeatureNames()) set.add(name);
87
88
  }
89
+ // auth-self-registration is only prepended when authOptions.signup is set
90
+ // (composeFeatures) — opt-in via HAS_SIGNUP so apps without signup don't
91
+ // get a stale-registry false positive (#1521).
92
+ if (mod.HAS_SIGNUP === true) {
93
+ set.add("auth-self-registration");
94
+ }
88
95
  return set;
89
96
  }
90
97
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "1.0.0",
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.",
3
+ "version": "2.0.0",
4
+ "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Not shipped into production node_modules see @cosmicdrift/kumiko-server-runtime for the prod boot path.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
7
  "repository": {
@@ -26,9 +26,13 @@
26
26
  "types": "./src/build.ts",
27
27
  "default": "./src/build.ts"
28
28
  },
29
- "./compose-features": {
30
- "types": "./src/compose-features.ts",
31
- "default": "./src/compose-features.ts"
29
+ "./compose-stacks": {
30
+ "types": "./src/compose-stacks.ts",
31
+ "default": "./src/compose-stacks.ts"
32
+ },
33
+ "./setup-test-stack-from-features": {
34
+ "types": "./src/setup-test-stack-from-features.ts",
35
+ "default": "./src/setup-test-stack-from-features.ts"
32
36
  },
33
37
  "./schema-apply": {
34
38
  "types": "./src/schema-apply.ts",
@@ -50,8 +54,9 @@
50
54
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
51
55
  },
52
56
  "dependencies": {
53
- "@cosmicdrift/kumiko-bundled-features": "1.0.0",
54
- "@cosmicdrift/kumiko-framework": "1.0.0",
57
+ "@cosmicdrift/kumiko-bundled-features": "2.0.0",
58
+ "@cosmicdrift/kumiko-framework": "2.0.0",
59
+ "@cosmicdrift/kumiko-server-runtime": "2.0.0",
55
60
  "ts-morph": "^28.0.0"
56
61
  },
57
62
  "publishConfig": {
@@ -19,7 +19,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
19
19
  import { tmpdir } from "node:os";
20
20
  import { dirname, join, resolve } from "node:path";
21
21
  import { fileURLToPath } from "node:url";
22
- import { buildProdBundle } from "../build-prod-bundle";
22
+ import { buildProdBundle } from "@cosmicdrift/kumiko-server-runtime/build-prod-bundle";
23
23
 
24
24
  const __filename = fileURLToPath(import.meta.url);
25
25
  const __dirname = dirname(__filename);
@@ -55,10 +55,72 @@ describe("buildServerBundle (multi-entry + splitting)", () => {
55
55
  expect(pkg.scripts.start).toBe("bun run server.js");
56
56
  expect(Object.keys(pkg.dependencies)).not.toContain("drizzle-kit");
57
57
  expect(Object.keys(pkg.dependencies)).not.toContain("drizzle-orm");
58
+ // Positive counterpart to the negative checks above — a runtime
59
+ // external accidentally dropped from RUNTIME_EXTERNALS (into
60
+ // BUILD_ONLY_EXTERNALS instead) would slip through silently. This
61
+ // is the exact regression class behind the money-horse prod crash
62
+ // ("Cannot find package 'meilisearch'").
63
+ expect(Object.keys(pkg.dependencies)).toContain("meilisearch");
58
64
 
59
65
  expect(result.totalBytes).toBeGreaterThan(0);
60
66
  } finally {
61
67
  rmSync(dir, { recursive: true, force: true });
62
68
  }
63
69
  });
70
+
71
+ test("pins runtime-deps from node_modules/@cosmicdrift/* for consumer apps without a packages/ dir", async () => {
72
+ // Reproduziert #1217: findRepoRoot liefert für eine Consumer-App die
73
+ // App-Wurzel selbst (kein packages/-Ordner) — die Version muss aus den
74
+ // installierten node_modules/@cosmicdrift/*-Packages kommen, nicht "*".
75
+ const dir = makeFixture();
76
+ try {
77
+ const frameworkPkgDir = join(dir, "node_modules/@cosmicdrift/kumiko-framework");
78
+ mkdirSync(frameworkPkgDir, { recursive: true });
79
+ writeFileSync(
80
+ join(frameworkPkgDir, "package.json"),
81
+ `${JSON.stringify({ name: "@cosmicdrift/kumiko-framework", dependencies: { meilisearch: "^0.58.0" } })}\n`,
82
+ );
83
+ const bundledFeaturesPkgDir = join(dir, "node_modules/@cosmicdrift/kumiko-bundled-features");
84
+ mkdirSync(bundledFeaturesPkgDir, { recursive: true });
85
+ writeFileSync(
86
+ join(bundledFeaturesPkgDir, "package.json"),
87
+ `${JSON.stringify({ name: "@cosmicdrift/kumiko-bundled-features", dependencies: { ioredis: "^5.4.1" } })}\n`,
88
+ );
89
+
90
+ const result = await buildServerBundle({ cwd: dir, outDir: join(dir, "dist-server") });
91
+
92
+ expect(result.runtimeDeps["meilisearch"]).toBe("^0.58.0");
93
+ expect(result.runtimeDeps["ioredis"]).toBe("^5.4.1");
94
+ } finally {
95
+ rmSync(dir, { recursive: true, force: true });
96
+ }
97
+ });
98
+
99
+ test("prefers node_modules/@cosmicdrift/* over packages/* when both declare a version", async () => {
100
+ // Framework-monorepo checkout: both the installed node_modules package
101
+ // (symlinked in real life, diverging here) and packages/framework exist.
102
+ // node_modules must win — it reflects what actually gets shipped/installed
103
+ // for the consumer, packages/framework is only a repo-root fallback.
104
+ const dir = makeFixture();
105
+ try {
106
+ const frameworkPkgDir = join(dir, "node_modules/@cosmicdrift/kumiko-framework");
107
+ mkdirSync(frameworkPkgDir, { recursive: true });
108
+ writeFileSync(
109
+ join(frameworkPkgDir, "package.json"),
110
+ `${JSON.stringify({ name: "@cosmicdrift/kumiko-framework", dependencies: { meilisearch: "^0.58.0" } })}\n`,
111
+ );
112
+
113
+ mkdirSync(join(dir, "packages/framework"), { recursive: true });
114
+ writeFileSync(
115
+ join(dir, "packages/framework/package.json"),
116
+ `${JSON.stringify({ name: "@cosmicdrift/kumiko-framework", dependencies: { meilisearch: "^0.30.0" } })}\n`,
117
+ );
118
+
119
+ const result = await buildServerBundle({ cwd: dir, outDir: join(dir, "dist-server") });
120
+
121
+ expect(result.runtimeDeps["meilisearch"]).toBe("^0.58.0");
122
+ } finally {
123
+ rmSync(dir, { recursive: true, force: true });
124
+ }
125
+ });
64
126
  });
@@ -0,0 +1,271 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
3
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
5
+ import {
6
+ composeFileStack,
7
+ composeGdprStack,
8
+ composeIdentityStack,
9
+ composeMailStack,
10
+ composeOpsStack,
11
+ composePagesStack,
12
+ composeRendererStack,
13
+ composeUserDataRightsStack,
14
+ stackFeatureNames,
15
+ } from "../compose-stacks";
16
+
17
+ const TEST_MFA = {
18
+ setupTokenSecret: "compose-stacks-mfa-setup-secret-at-least-32b!!",
19
+ challengeTokenSecret: "compose-stacks-mfa-challenge-secret-32b!!",
20
+ issuer: "Kumiko ComposeStacks",
21
+ } as const;
22
+
23
+ describe("composeStacks", () => {
24
+ test("composeRendererStack → template-resolver, renderer-foundation, renderer-simple", () => {
25
+ expect(stackFeatureNames(composeRendererStack())).toEqual([
26
+ "template-resolver",
27
+ "renderer-foundation",
28
+ "renderer-simple",
29
+ ]);
30
+ });
31
+
32
+ test("composePagesStack → text-content + legal-pages", () => {
33
+ expect(stackFeatureNames(composePagesStack())).toEqual(["text-content", "legal-pages"]);
34
+ });
35
+
36
+ test("composeMailStack mounts foundation + selected transports", () => {
37
+ expect(stackFeatureNames(composeMailStack({ transports: ["inmemory", "smtp"] }))).toEqual([
38
+ "mail-foundation",
39
+ "mail-transport-inmemory",
40
+ "mail-transport-smtp",
41
+ ]);
42
+ });
43
+
44
+ test("composeFileStack mounts foundation + providers + files entity", () => {
45
+ expect(stackFeatureNames(composeFileStack({ providers: ["inmemory", "s3"] }))).toEqual([
46
+ "file-foundation",
47
+ "file-provider-inmemory",
48
+ "file-provider-s3",
49
+ "files",
50
+ ]);
51
+ });
52
+
53
+ test("composeGdprStack retention-first (money-horse / publicstatus pattern)", () => {
54
+ expect(stackFeatureNames(composeGdprStack({ tenantLifecycle: true, sessions: true }))).toEqual([
55
+ "data-retention",
56
+ "compliance-profiles",
57
+ "tenant-lifecycle",
58
+ "auth-foundation",
59
+ "sessions",
60
+ ]);
61
+ });
62
+
63
+ test("composeGdprStack compliance-first (studio pattern)", () => {
64
+ expect(stackFeatureNames(composeGdprStack({ order: "compliance-first" }))).toEqual([
65
+ "compliance-profiles",
66
+ "data-retention",
67
+ ]);
68
+ });
69
+
70
+ test("composeUserDataRightsStack → udr + defaults", () => {
71
+ expect(stackFeatureNames(composeUserDataRightsStack())).toEqual([
72
+ "user-data-rights",
73
+ "user-data-rights-defaults",
74
+ ]);
75
+ });
76
+
77
+ test("composeOpsStack defaults delivery + audit + jobs", () => {
78
+ expect(stackFeatureNames(composeOpsStack())).toEqual(["delivery", "audit", "jobs"]);
79
+ });
80
+
81
+ test("composeOpsStack rateLimiting opt-in", () => {
82
+ expect(stackFeatureNames(composeOpsStack({ rateLimiting: true }))).toEqual([
83
+ "delivery",
84
+ "audit",
85
+ "jobs",
86
+ "rate-limiting",
87
+ ]);
88
+ });
89
+
90
+ test("composeIdentityStack defaults auth-foundation + sessions", () => {
91
+ expect(stackFeatureNames(composeIdentityStack())).toEqual(["auth-foundation", "sessions"]);
92
+ });
93
+
94
+ test("composeIdentityStack mounts auth-mfa when mfa options given", () => {
95
+ expect(stackFeatureNames(composeIdentityStack({ mfa: TEST_MFA }))).toEqual([
96
+ "auth-foundation",
97
+ "sessions",
98
+ "auth-mfa",
99
+ ]);
100
+ });
101
+
102
+ test("composeIdentityStack sessions:false + mfa → auth-mfa only", () => {
103
+ expect(stackFeatureNames(composeIdentityStack({ sessions: false, mfa: TEST_MFA }))).toEqual([
104
+ "auth-mfa",
105
+ ]);
106
+ });
107
+
108
+ test("composeIdentityStack + composeGdprStack(sessions) → duplicate feature rejected at boot", () => {
109
+ const names = stackFeatureNames([
110
+ ...composeIdentityStack(),
111
+ ...composeGdprStack({ sessions: true }),
112
+ ]);
113
+ expect(names.filter((n) => n === "sessions")).toHaveLength(2);
114
+ expect(() =>
115
+ validateBoot(
116
+ composeFeatures(
117
+ [
118
+ createPersonalAccessTokensFeature({ scopes: {} }),
119
+ ...composeIdentityStack(),
120
+ ...composeGdprStack({ sessions: true }),
121
+ ],
122
+ { includeBundled: true },
123
+ ),
124
+ ),
125
+ ).toThrow(/duplicate feature/i);
126
+ });
127
+ });
128
+
129
+ /** Snapshot, not a live parity check: intended block combinations, mirrored by hand from studio/money-horse/publicstatus run-configs. A drifted run-config stays green here. */
130
+ describe("composeStacks boots for real", () => {
131
+ test("studio-shaped combined stack passes validateBoot (not just name-list comparison)", () => {
132
+ const features = composeFeatures(
133
+ [
134
+ createPersonalAccessTokensFeature({ scopes: {} }),
135
+ ...composeOpsStack({ rateLimiting: true }),
136
+ ...composePagesStack(),
137
+ ...composeMailStack({ transports: ["inmemory"] }),
138
+ ...composeFileStack({ providers: ["inmemory"] }),
139
+ ...composeGdprStack({ order: "compliance-first", sessions: true }),
140
+ ...composeUserDataRightsStack(),
141
+ ],
142
+ { includeBundled: true },
143
+ );
144
+ expect(() => validateBoot(features)).not.toThrow();
145
+ });
146
+
147
+ test("money-horse-shaped composeGdprStack({sessions:true}) without a tokenVerifier provider passes boot", () => {
148
+ // Session-only stacks (#1570): composeGdprStack({sessions:true}) mounts
149
+ // auth-foundation + sessions (sessionStore). Cookie auth does not need a
150
+ // tokenVerifier; money-horse/solon run-configs pass none and must boot.
151
+ const features = composeFeatures([...composeGdprStack({ sessions: true })], {
152
+ includeBundled: true,
153
+ });
154
+ expect(() => validateBoot(features)).not.toThrow();
155
+ });
156
+
157
+ test("composeGdprStack({sessions:true, providers:[...]}) passes boot with a tokenVerifier mounted", () => {
158
+ const features = composeFeatures(
159
+ [
160
+ ...composeGdprStack({
161
+ sessions: true,
162
+ providers: [createPersonalAccessTokensFeature({ scopes: {} })],
163
+ }),
164
+ ],
165
+ { includeBundled: true },
166
+ );
167
+ expect(() => validateBoot(features)).not.toThrow();
168
+ });
169
+
170
+ // auth-mfa's encrypted fields need a runtime KEK (env or configureEntityFieldEncryption
171
+ // + secrets). Name-list + saas-identity-wire.integration.test.ts cover identity boot.
172
+ test("identity stack names compose with ops + renderer under includeBundled", () => {
173
+ const features = composeFeatures(
174
+ [
175
+ ...composeIdentityStack({ mfa: TEST_MFA }),
176
+ ...composeOpsStack(),
177
+ ...composeRendererStack(),
178
+ ...composeMailStack({ transports: ["inmemory"] }),
179
+ ],
180
+ { includeBundled: true },
181
+ );
182
+ const names = stackFeatureNames(features);
183
+ expect(names).toContain("auth-email-password");
184
+ expect(names).toContain("sessions");
185
+ expect(names).toContain("auth-mfa");
186
+ expect(names).toContain("delivery");
187
+ });
188
+ });
189
+
190
+ describe("composeStacks intended block names (snapshot, not live parity)", () => {
191
+ test("studio SaaS blocks are covered by presets", () => {
192
+ const names = stackFeatureNames([
193
+ ...composeOpsStack({ rateLimiting: true }),
194
+ ...composePagesStack(),
195
+ ...composeMailStack({ transports: ["inmemory", "smtp"] }),
196
+ ...composeFileStack({ providers: ["inmemory", "s3"] }),
197
+ ...composeGdprStack({ order: "compliance-first" }),
198
+ ...composeUserDataRightsStack(),
199
+ ]);
200
+ for (const expected of [
201
+ "delivery",
202
+ "audit",
203
+ "jobs",
204
+ "rate-limiting",
205
+ "text-content",
206
+ "legal-pages",
207
+ "mail-foundation",
208
+ "file-foundation",
209
+ "files",
210
+ "compliance-profiles",
211
+ "data-retention",
212
+ "user-data-rights",
213
+ "user-data-rights-defaults",
214
+ ]) {
215
+ expect(names).toContain(expected);
216
+ }
217
+ });
218
+
219
+ test("money-horse GDPR + file block names", () => {
220
+ const names = stackFeatureNames([
221
+ ...composePagesStack(),
222
+ ...composeRendererStack(),
223
+ ...composeOpsStack({ delivery: true, audit: false, jobs: false }),
224
+ ...composeGdprStack({ sessions: true }),
225
+ ...composeFileStack({ providers: ["s3-env"] }),
226
+ ...composeUserDataRightsStack(),
227
+ ]);
228
+ for (const expected of [
229
+ "text-content",
230
+ "legal-pages",
231
+ "data-retention",
232
+ "compliance-profiles",
233
+ "sessions",
234
+ "file-foundation",
235
+ "file-provider-s3-env",
236
+ "files",
237
+ "user-data-rights",
238
+ ]) {
239
+ expect(names).toContain(expected);
240
+ }
241
+ });
242
+
243
+ test("publicstatus core stack block names", () => {
244
+ const names = stackFeatureNames([
245
+ ...composePagesStack(),
246
+ ...composeGdprStack({ tenantLifecycle: true }),
247
+ ...composeOpsStack({ delivery: true, audit: true, jobs: true }),
248
+ ...composeRendererStack(),
249
+ ...composeFileStack({ providers: ["inmemory"] }),
250
+ ...composeUserDataRightsStack(),
251
+ ]);
252
+ for (const expected of [
253
+ "text-content",
254
+ "legal-pages",
255
+ "tenant-lifecycle",
256
+ "audit",
257
+ "delivery",
258
+ "jobs",
259
+ "renderer-simple",
260
+ "files",
261
+ ]) {
262
+ expect(names).toContain(expected);
263
+ }
264
+ });
265
+
266
+ test("identity stack covers sessions + auth-mfa", () => {
267
+ const names = stackFeatureNames(composeIdentityStack({ mfa: TEST_MFA }));
268
+ expect(names).toContain("sessions");
269
+ expect(names).toContain("auth-mfa");
270
+ });
271
+ });
@@ -0,0 +1,54 @@
1
+ // createKumikoServer error / graceful-degradation paths — boot rejects,
2
+ // stylesheet pipeline failures, missing CSS route.
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { createKumikoServer } from "../create-kumiko-server";
10
+
11
+ const emptyFeature = defineFeature("dev-server-errors-probe", () => {});
12
+
13
+ describe("createKumikoServer — client bundle failure", () => {
14
+ test("broken clientEntry rejects at boot with client bundle failed", async () => {
15
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-bundle-fail-"));
16
+ const entry = join(tmpDir, "client.tsx");
17
+ writeFileSync(entry, "const x = {{{\n");
18
+ try {
19
+ await expect(
20
+ createKumikoServer({
21
+ features: [emptyFeature],
22
+ port: 0,
23
+ installSignalHandlers: false,
24
+ clientEntry: entry,
25
+ stylesheet: false,
26
+ }),
27
+ ).rejects.toThrow(/client bundle failed|Bundle failed/);
28
+ } finally {
29
+ rmSync(tmpDir, { recursive: true, force: true });
30
+ }
31
+ });
32
+
33
+ test("_buildBundle throw propagates at boot", async () => {
34
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-stub-fail-"));
35
+ const entry = join(tmpDir, "client.tsx");
36
+ writeFileSync(entry, "// noop\n");
37
+ try {
38
+ await expect(
39
+ createKumikoServer({
40
+ features: [emptyFeature],
41
+ port: 0,
42
+ installSignalHandlers: false,
43
+ clientEntry: entry,
44
+ stylesheet: false,
45
+ _buildBundle: async () => {
46
+ throw new Error("stub build blew up");
47
+ },
48
+ }),
49
+ ).rejects.toThrow(/stub build blew up/);
50
+ } finally {
51
+ rmSync(tmpDir, { recursive: true, force: true });
52
+ }
53
+ });
54
+ });
@@ -0,0 +1,33 @@
1
+ // Option/error matrix for createKumikoServer — pins boot-time rejects
2
+ // that happen before Postgres/Redis wiring (normalizeEntries).
3
+
4
+ import { describe, expect, test } from "bun:test";
5
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { createKumikoServer } from "../create-kumiko-server";
7
+
8
+ const emptyFeature = defineFeature("prod-packaging-options-probe", () => {});
9
+
10
+ describe("createKumikoServer — option matrix", () => {
11
+ test("clientEntry + clientEntries → mutually exclusive error", async () => {
12
+ await expect(
13
+ createKumikoServer({
14
+ features: [emptyFeature],
15
+ clientEntry: "src/client.tsx",
16
+ clientEntries: [{ name: "admin", sourceFile: "src/client-admin.tsx" }],
17
+ installSignalHandlers: false,
18
+ port: 0,
19
+ }),
20
+ ).rejects.toThrow(/mutually exclusive/);
21
+ });
22
+
23
+ test("clientEntries without hostDispatch → error", async () => {
24
+ await expect(
25
+ createKumikoServer({
26
+ features: [emptyFeature],
27
+ clientEntries: [{ name: "admin", sourceFile: "src/client-admin.tsx" }],
28
+ installSignalHandlers: false,
29
+ port: 0,
30
+ }),
31
+ ).rejects.toThrow(/hostDispatch/);
32
+ });
33
+ });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -336,3 +336,130 @@ describe("createKumikoServer extraRoutes-deps", () => {
336
336
  expect(body.data?.roles).toContain("SystemAdmin");
337
337
  });
338
338
  });
339
+
340
+ describe("createKumikoServer — stylesheet tailwind failure (graceful)", () => {
341
+ test("missing stylesheet entry boots without CSS — GET /styles.css → 404", async () => {
342
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-css-fail-"));
343
+ const entry = join(tmpDir, "client.tsx");
344
+ writeFileSync(entry, "export const x = 1;\n");
345
+ try {
346
+ handle = await createKumikoServer({
347
+ features: [probeFeature],
348
+ port: 0,
349
+ installSignalHandlers: false,
350
+ clientEntry: entry,
351
+ stylesheet: join(tmpDir, "does-not-exist.css"),
352
+ _buildBundle: async () => ({ js: "// stub", map: "" }),
353
+ });
354
+ const cssRes = await handle.fetch(new Request("http://localhost/styles.css"));
355
+ expect(cssRes.status).toBe(404);
356
+ expect(await cssRes.text()).toBe("no stylesheet");
357
+ } finally {
358
+ rmSync(tmpDir, { recursive: true, force: true });
359
+ }
360
+ });
361
+ });
362
+
363
+ describe("createKumikoServer — real Bun.build (buildClient)", () => {
364
+ test("clientEntry without _buildBundle produces a JS bundle via Bun.build", async () => {
365
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-real-build-"));
366
+ const entry = join(tmpDir, "client.tsx");
367
+ // Minimal entry Bun.build can emit — no JSX, no imports.
368
+ writeFileSync(entry, "export const ping = 1;\n");
369
+ // Empty dirs matching a glob — exercises expandWatchPatterns without
370
+ // writing files (avoids process.exit(75) from bare .tsx events).
371
+ mkdirSync(join(tmpDir, "pkg-a"));
372
+ mkdirSync(join(tmpDir, "pkg-b"));
373
+ try {
374
+ handle = await createKumikoServer({
375
+ features: [probeFeature],
376
+ port: 0,
377
+ installSignalHandlers: false,
378
+ clientEntry: entry,
379
+ stylesheet: false,
380
+ watchDirs: [join(tmpDir, "pkg-*")],
381
+ });
382
+ const res = await handle.fetch(new Request("http://localhost/client.js"));
383
+ expect(res.status).toBe(200);
384
+ expect(res.headers.get("content-type")).toMatch(/application\/javascript/);
385
+ const body = await res.text();
386
+ expect(body.length).toBeGreaterThan(0);
387
+ expect(body).toMatch(/ping/);
388
+
389
+ // Stop the watcher before teardown rmSync deletes clientEntry out from
390
+ // under it — a bare "client.tsx" delete event classifies as "restart"
391
+ // (classifyChange only special-cases endsWith("/client.tsx")) and
392
+ // process.exit(75) kills the whole bun test runner mid-suite.
393
+ await handle.stop();
394
+ handle = undefined;
395
+ } finally {
396
+ rmSync(tmpDir, { recursive: true, force: true });
397
+ }
398
+ });
399
+ });
400
+
401
+ describe("createKumikoServer — hot-reload broadcast", () => {
402
+ test("file change under web/ rebuilds and broadcasts SSE reload", async () => {
403
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-watch-"));
404
+ const entry = join(tmpDir, "client.tsx");
405
+ const webDir = join(tmpDir, "web");
406
+ mkdirSync(webDir);
407
+ writeFileSync(entry, "export const ping = 1;\n");
408
+
409
+ let builds = 0;
410
+ try {
411
+ handle = await createKumikoServer({
412
+ features: [probeFeature],
413
+ port: 0,
414
+ installSignalHandlers: false,
415
+ clientEntry: entry,
416
+ stylesheet: false,
417
+ // Only the entry dir is watched (no extra watchDirs) — a nested
418
+ // web/page.tsx event arrives as "web/page.tsx" → hot-reload.
419
+ // Watching web/ separately would fire bare "page.tsx" → restart
420
+ // → process.exit(75) and kill the test runner.
421
+ _buildBundle: async () => {
422
+ builds += 1;
423
+ return { js: `// build-${builds}`, map: "" };
424
+ },
425
+ });
426
+
427
+ const sseRes = await handle.fetch(new Request("http://localhost/_reload"));
428
+ expect(sseRes.status).toBe(200);
429
+ const reader = sseRes.body?.getReader();
430
+ expect(reader).toBeDefined();
431
+ if (!reader) return;
432
+
433
+ await reader.read(); // drain connected comment
434
+
435
+ const initialBuilds = builds;
436
+ writeFileSync(join(webDir, "page.tsx"), "export const x = 1;\n");
437
+
438
+ const deadline = Date.now() + 3000;
439
+ let sawReload = false;
440
+ while (Date.now() < deadline && !sawReload) {
441
+ const readPromise = reader.read();
442
+ const timeout = new Promise<{ done: true; value: undefined }>((resolve) =>
443
+ setTimeout(() => resolve({ done: true, value: undefined }), 200),
444
+ );
445
+ const { value, done } = await Promise.race([readPromise, timeout]);
446
+ if (done || value === undefined) continue;
447
+ const chunk = new TextDecoder().decode(value);
448
+ if (chunk.includes("event: reload")) sawReload = true;
449
+ }
450
+ await reader.cancel();
451
+
452
+ expect(builds).toBeGreaterThan(initialBuilds);
453
+ expect(sawReload).toBe(true);
454
+
455
+ const js = await handle.fetch(new Request("http://localhost/client.js"));
456
+ expect(await js.text()).toMatch(/build-/);
457
+
458
+ // Abort watchers before teardown rmSync can fire a restart event.
459
+ await handle.stop();
460
+ handle = undefined;
461
+ } finally {
462
+ rmSync(tmpDir, { recursive: true, force: true });
463
+ }
464
+ });
465
+ });
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { formatBuildResult } from "../build-prod-bundle";
5
+ import { formatBuildResult } from "@cosmicdrift/kumiko-server-runtime/build-prod-bundle";
6
6
  import { discoverServerEntry } from "../build-server-bundle";
7
7
 
8
8
  describe("discoverServerEntry", () => {
@@ -7,10 +7,10 @@
7
7
  import { describe, expect, it } from "bun:test";
8
8
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { composeEnvSchema, KumikoBootError } from "@cosmicdrift/kumiko-framework/env";
10
+ import { runProdApp } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
10
11
  import { z } from "zod";
11
12
  import { frameworkCoreEnvSchema } from "../env-schema";
12
13
  import * as devServerPublicApi from "../index";
13
- import { runProdApp } from "../run-prod-app";
14
14
 
15
15
  const secretsFeature = defineFeature("secrets", (r) => {
16
16
  r.envSchema(