@cosmicdrift/kumiko-dev-server 0.105.1 → 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.
@@ -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.