@cosmicdrift/kumiko-dev-server 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,113 @@
1
1
  # @cosmicdrift/kumiko-dev-server
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 7bd5c88: `KUMIKO_DRY_RUN_ENV=boot` mode for runProdApp — runs env-validation +
8
+ composeFeatures + validateBoot + createRegistry without DB/Redis
9
+ connect, exits with status 0 on success. Used by the
10
+ `samples/apps/use-all-bundled` smoke-app (Sprint 9.8 Phase C / Empfehlung
11
+ 1 / canonical bug-catcher) and downstream by enterprise's
12
+ `use-all-features` mirror. Render-modes (human|json|pulumi|k8s|1)
13
+ behavior unchanged.
14
+ - 575752f: `scaffoldAppFeature` + `kumiko add feature <name>` — DX-2 aus DX-Roadmap.
15
+ Scaffolded ein neues Feature in `src/features/<name>/` einer bereits via
16
+ `kumiko new app` scaffolded App + **auto-mountet** es in `src/run-config.ts`
17
+ via ts-morph (import + `APP_FEATURES`-array-entry, idempotent).
18
+
19
+ User-Promise "defineFeature → nichts woanders eintragen" erfüllt für die
20
+ run-config-Seite. FEATURE_IMPORT_REGISTRY in drizzle/generate.ts ist
21
+ DX-4's Refactor — bei DX-1+DX-2-App noch nicht vorhanden.
22
+
23
+ Usage (in einer DX-1-gescaffoldeten App):
24
+
25
+ ```sh
26
+ bunx kumiko add feature product-catalog
27
+ # → src/features/product-catalog/{feature.ts,index.ts}
28
+ # → src/run-config.ts auto-edited: import + APP_FEATURES-entry
29
+ ```
30
+
31
+ - 3d5e9ef: `kumiko-schema-check` CLI — Empfehlung 3 aus Sprint-9.8-Retro
32
+ (`luminous-watching-moler.md`). Diff't APP_FEATURES (runtime, aus
33
+ `src/run-config.ts`) gegen FEATURE_IMPORT_REGISTRY (statisch, aus
34
+ `drizzle/generate.ts`). Fängt Studio's 9.8-Drama: registry 18 features
35
+ hinter APP_FEATURES → migrations fehlten für mounted features.
36
+
37
+ Usage (im app-workspace):
38
+
39
+ ```sh
40
+ bunx kumiko-schema-check
41
+ # or with custom paths:
42
+ bunx kumiko-schema-check --run-config src/run-config.ts --generate drizzle/generate.ts
43
+ ```
44
+
45
+ Plus: 5 bundled-features hatten camelCase feature-names statt kebab-case
46
+ (Memory `feedback_kebab_aggregates`) — aufgedeckt durch den schema-check
47
+ gegen use-all-bundled. Fix: `channelEmail` → `channel-email`,
48
+ `channelInApp` → `channel-in-app`, `channelPush` → `channel-push`,
49
+ `rateLimiting` → `rate-limiting`, `rendererSimple` → `renderer-simple`.
50
+
51
+ Plus `CHANNEL_IN_APP_FEATURE` und `RATE_LIMITING_FEATURE` Konstanten
52
+ angepasst (waren intern auf camelCase, jetzt kebab-case).
53
+
54
+ - 46b84d0: `scaffoldApp` + `kumiko new app <name>` — DX-1.0 aus DX-Roadmap. Generiert
55
+ ein lauffähiges App-Skelett (package.json, tsconfig, run-config mit
56
+ secrets+sessions, bin/main.ts mit auth-admin-stub + deterministische
57
+ tenant-UUID, .env.example, README) in `<cwd>/<name>/`.
58
+
59
+ Boot-Pfad: `KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts` läuft ohne DB/Redis.
60
+
61
+ Held-back für spätere DX-Phasen: drizzle-setup (DX-1.1, blocked-by DX-4
62
+ auto-registry), Dockerfile (existing `kumiko init-deploy`), first feature
63
+ scaffold (existing `kumiko create` bzw. DX-2 `kumiko add feature`).
64
+
65
+ Usage:
66
+
67
+ ```sh
68
+ bunx kumiko new app my-shop
69
+ cd my-shop && yarn install
70
+ cp .env.example .env # JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1 setzen
71
+ bun run boot # → boot validation OK
72
+ ```
73
+
74
+ ### Patch Changes
75
+
76
+ - 2bd60c1: `buildServerBundle` BUILD_ONLY_EXTERNALS erweitert um drizzle-kit's
77
+ dialect-resolver dynamic-imports: `@planetscale/database`, `@libsql/client`,
78
+ `better-sqlite3`, `@neondatabase/serverless`, `@vercel/postgres`, `mysql2`.
79
+
80
+ Aufgedeckt durch C1 Empfehlung 4 (bundle-smoke). Bisher schlug
81
+ `bun build` an dynamic-imports im drizzle-kit auch wenn der App nur
82
+ postgres nutzt. Externalisieren = build durchläuft + tree-shake wirft
83
+ die ungenutzten driver-modules eh raus.
84
+
85
+ - 8bfb284: Dockerfile.template setzt `YARN_ENABLE_SCRIPTS=false` im Build-Stage. Fixt msgpackr-extract native-build-Failures (ARM, CI) und generell jeden transitiven Native-Dep — der Build-Stage bundlet nur JS via `bun build`, Runtime-Native-Deps werden separat im Runtime-Stage via `bun install --production` installiert. Apps die bisher per-package-Workarounds via `dependenciesMeta.<pkg>.built=false` in der App-package.json brauchten (studio, enterprise) können diese Entries nach Upgrade auf diese dev-server-Version entfernen.
86
+ - cc0ddc0: `Dockerfile.template` emits an inline `start.sh` for createBunServer command-override target.
87
+
88
+ `infra/pulumi/bun-server.ts`'s `createBunServer` overrides the container command with `exec ./start.sh` after injecting DATABASE_URL from the init-container. Apps deployed via createBunServer crashed with `./start.sh: not found` until each one added a per-app `start.sh` in repo root (= studio's PR #22).
89
+
90
+ Now the Dockerfile-template emits the file inline (`RUN printf … > ./start.sh && chmod +x`). Apps no longer need to ship one — the runtime stage generates it. Apps that don't go through createBunServer's command-override still boot via the bottom CMD; start.sh is dead-code in that case.
91
+
92
+ - Updated dependencies [7f56b2f]
93
+ - Updated dependencies [68b8118]
94
+ - Updated dependencies [9121928]
95
+ - Updated dependencies [72518fa]
96
+ - Updated dependencies [0a00e7b]
97
+ - Updated dependencies [aca1443]
98
+ - Updated dependencies [c6cb96c]
99
+ - Updated dependencies [3d5e9ef]
100
+ - @cosmicdrift/kumiko-framework@0.13.0
101
+ - @cosmicdrift/kumiko-bundled-features@0.13.0
102
+
103
+ ## 0.12.2
104
+
105
+ ### Patch Changes
106
+
107
+ - Updated dependencies [597de52]
108
+ - @cosmicdrift/kumiko-framework@0.12.2
109
+ - @cosmicdrift/kumiko-bundled-features@0.12.2
110
+
3
111
  ## 0.12.1
4
112
 
5
113
  ### Patch Changes
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env bun
2
+ // biome-ignore-all lint/suspicious/noConsole: CLI-Script, console ist Feature.
3
+ //
4
+ // kumiko schema check — Empfehlung 3 aus Sprint-9.8-Retro
5
+ // (luminous-watching-moler.md). Diff't APP_FEATURES (runtime, aus
6
+ // `src/run-config.ts`) gegen FEATURE_IMPORT_REGISTRY (statisch, aus
7
+ // `drizzle/generate.ts`). Catches:
8
+ //
9
+ // 1. Mount-without-registry: ein neues feature in APP_FEATURES ohne
10
+ // Entry in FEATURE_IMPORT_REGISTRY. Resultiert in Schema-Drift:
11
+ // Runtime mountet feature, Migration kennt seine Tabellen nicht.
12
+ // 2. Stale-registry: ein Entry in FEATURE_IMPORT_REGISTRY ohne
13
+ // matching mount in APP_FEATURES. Dead-code; im Schema entsteht
14
+ // eine Tabelle ohne Runtime-Konsument.
15
+ //
16
+ // Studio's 9.8-Drama: FEATURE_IMPORT_REGISTRY war 18 features hinter
17
+ // APP_FEATURES. Hätte mit diesem check eine Sekunde Lokal gefangen.
18
+ //
19
+ // Usage (aus dem app-workspace):
20
+ // bunx kumiko-schema-check
21
+ // # oder mit explicit pfaden:
22
+ // bunx kumiko-schema-check --run-config src/run-config.ts --generate drizzle/generate.ts
23
+ //
24
+ // Exit 0 wenn alles in sync, exit 1 wenn drift.
25
+
26
+ import { existsSync, readFileSync } from "node:fs";
27
+ import { resolve } from "node:path";
28
+
29
+ type Args = {
30
+ readonly runConfigPath: string;
31
+ readonly generatePath: string;
32
+ };
33
+
34
+ function parseArgs(argv: readonly string[]): Args {
35
+ const cwd = process.cwd();
36
+ let runConfigPath = resolve(cwd, "src/run-config.ts");
37
+ let generatePath = resolve(cwd, "drizzle/generate.ts");
38
+ for (let i = 0; i < argv.length; i++) {
39
+ const flag = argv[i];
40
+ const value = argv[i + 1];
41
+ if (flag === "--run-config" && value) {
42
+ runConfigPath = resolve(cwd, value);
43
+ i++;
44
+ } else if (flag === "--generate" && value) {
45
+ generatePath = resolve(cwd, value);
46
+ i++;
47
+ }
48
+ }
49
+ return { runConfigPath, generatePath };
50
+ }
51
+
52
+ function readRegistryFeatures(generateSrc: string): Set<string> {
53
+ // Match object-keys mit Discriminator `kind: "factory" | "named"`.
54
+ // Quoted ("billing-foundation") oder unquoted (config, user) — beides
55
+ // ist gültig in JS. Pattern aus use-all-bundled/scripts/check-coverage.ts
56
+ // dupliziert hier weil per-app-CLI nicht von sample-script lesen darf.
57
+ const re = /(?:"([a-z][a-z0-9-]*)"|([a-z][a-z0-9]*)):\s*\{\s*kind:\s*"(factory|named)"/g;
58
+ const out = new Set<string>();
59
+ for (const m of generateSrc.matchAll(re)) {
60
+ const name = m[1] ?? m[2];
61
+ if (name) out.add(name);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ async function readMountedFeatures(runConfigPath: string): Promise<Set<string>> {
67
+ const mod = (await import(runConfigPath)) as {
68
+ APP_FEATURES?: ReadonlyArray<{ name: string }>;
69
+ HAS_AUTH?: boolean;
70
+ };
71
+ if (!mod.APP_FEATURES) {
72
+ throw new Error(
73
+ `kumiko-schema-check: ${runConfigPath} hat kein 'APP_FEATURES' export. ` +
74
+ `Convention: 'export const APP_FEATURES = [...] as const'.`,
75
+ );
76
+ }
77
+ // composeFeatures auto-prepends config + user + tenant + auth-email-
78
+ // password im auth-mode. Wenn HAS_AUTH=true (oder default), die 4
79
+ // implicit-mounted features auch in die Set tun damit der Diff nicht
80
+ // false-positive "auth-email-password is mounted but no registry-entry"
81
+ // produziert. Studio/use-all-bundled HAS_AUTH=true ist convention.
82
+ const set = new Set<string>();
83
+ for (const f of mod.APP_FEATURES) {
84
+ set.add(f.name);
85
+ }
86
+ if (mod.HAS_AUTH ?? true) {
87
+ set.add("config");
88
+ set.add("user");
89
+ set.add("tenant");
90
+ set.add("auth-email-password");
91
+ }
92
+ return set;
93
+ }
94
+
95
+ async function main(): Promise<void> {
96
+ const args = parseArgs(process.argv.slice(2));
97
+
98
+ if (!existsSync(args.runConfigPath)) {
99
+ console.error(`✗ run-config not found: ${args.runConfigPath}`);
100
+ process.exit(1);
101
+ }
102
+ if (!existsSync(args.generatePath)) {
103
+ console.error(`✗ generate not found: ${args.generatePath}`);
104
+ process.exit(1);
105
+ }
106
+
107
+ const registry = readRegistryFeatures(readFileSync(args.generatePath, "utf-8"));
108
+ const mounted = await readMountedFeatures(args.runConfigPath);
109
+
110
+ // Mounted but not in registry → schema-drift (runtime ↔ migration mismatch).
111
+ const mountedWithoutEntry: string[] = [];
112
+ for (const name of mounted) {
113
+ if (!registry.has(name)) mountedWithoutEntry.push(name);
114
+ }
115
+ // In registry but not mounted → stale entry (dead schema-mapping).
116
+ const staleEntries: string[] = [];
117
+ for (const name of registry) {
118
+ if (!mounted.has(name)) staleEntries.push(name);
119
+ }
120
+
121
+ let ok = true;
122
+
123
+ if (mountedWithoutEntry.length > 0) {
124
+ console.error(
125
+ `\n✗ ${mountedWithoutEntry.length} feature(s) mounted in APP_FEATURES but NOT in FEATURE_IMPORT_REGISTRY:`,
126
+ );
127
+ for (const name of mountedWithoutEntry.sort()) {
128
+ console.error(` - ${name}`);
129
+ }
130
+ console.error(
131
+ "\n Action: in drizzle/generate.ts FEATURE_IMPORT_REGISTRY den Eintrag ergänzen,",
132
+ );
133
+ console.error(" damit Schema-Generator + Migration die feature-Tabellen kennt.");
134
+ ok = false;
135
+ }
136
+
137
+ if (staleEntries.length > 0) {
138
+ console.error(
139
+ `\n✗ ${staleEntries.length} stale FEATURE_IMPORT_REGISTRY entry/entries (kein matching mount):`,
140
+ );
141
+ for (const name of staleEntries.sort()) {
142
+ console.error(` - ${name}`);
143
+ }
144
+ console.error("\n Action: entry aus drizzle/generate.ts FEATURE_IMPORT_REGISTRY entfernen,");
145
+ console.error(" oder das feature in src/run-config.ts mounten.");
146
+ ok = false;
147
+ }
148
+
149
+ if (ok) {
150
+ console.log(
151
+ `✓ schema check: ${mounted.size} mounted ↔ ${registry.size} registry entries, no drift`,
152
+ );
153
+ process.exit(0);
154
+ } else {
155
+ process.exit(1);
156
+ }
157
+ }
158
+
159
+ await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.12.1",
3
+ "version": "0.13.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>",
@@ -45,11 +45,12 @@
45
45
  },
46
46
  "bin": {
47
47
  "kumiko-build": "./bin/kumiko-build.ts",
48
- "kumiko-dev": "./bin/kumiko-dev.ts"
48
+ "kumiko-dev": "./bin/kumiko-dev.ts",
49
+ "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
49
50
  },
50
51
  "dependencies": {
51
- "@cosmicdrift/kumiko-bundled-features": "0.12.1",
52
- "@cosmicdrift/kumiko-framework": "0.12.1"
52
+ "@cosmicdrift/kumiko-bundled-features": "0.13.0",
53
+ "@cosmicdrift/kumiko-framework": "0.13.0"
53
54
  },
54
55
  "publishConfig": {
55
56
  "registry": "https://registry.npmjs.org",
@@ -182,4 +182,54 @@ describe("runProdApp envSchema integration", () => {
182
182
  console.log = realLog;
183
183
  }
184
184
  });
185
+
186
+ it("KUMIKO_DRY_RUN_ENV=boot runs validators + exits before DB-connect", async () => {
187
+ const logs: string[] = [];
188
+ const realLog = console.log;
189
+ console.log = (...args: unknown[]) => {
190
+ logs.push(args.map(String).join(" "));
191
+ };
192
+ try {
193
+ const handle = await runProdApp({
194
+ features: [secretsFeature, authFeature],
195
+ envSchema: composed,
196
+ envSource: {
197
+ KUMIKO_DRY_RUN_ENV: "boot",
198
+ KUMIKO_SECRETS_MASTER_KEY_V1: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
199
+ JWT_SECRET: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
200
+ STUDIO_ADMIN_EMAIL: "ops@example.com",
201
+ DATABASE_URL: "postgres://dummy:dummy@127.0.0.1:1/dummy",
202
+ REDIS_URL: "redis://127.0.0.1:1",
203
+ },
204
+ migrations: false,
205
+ });
206
+ expect(logs.join("\n")).toContain("boot validation OK");
207
+ expect(handle).toBeDefined();
208
+ } finally {
209
+ console.log = realLog;
210
+ }
211
+ });
212
+
213
+ it("KUMIKO_DRY_RUN_ENV=boot still aggregates env-errors before exit", async () => {
214
+ let captured: KumikoBootError | undefined;
215
+ try {
216
+ await runProdApp({
217
+ features: [secretsFeature, authFeature],
218
+ envSchema: composed,
219
+ envSource: {
220
+ KUMIKO_DRY_RUN_ENV: "boot",
221
+ JWT_SECRET: "short",
222
+ STUDIO_ADMIN_EMAIL: "not-an-email",
223
+ },
224
+ bootErrorReporter: (err) => {
225
+ captured = err;
226
+ throw err;
227
+ },
228
+ });
229
+ } catch (err) {
230
+ expect(err).toBeInstanceOf(KumikoBootError);
231
+ }
232
+ expect(captured).toBeDefined();
233
+ expect(captured!.errors.length).toBeGreaterThanOrEqual(3);
234
+ });
185
235
  });
@@ -0,0 +1,88 @@
1
+ // scaffoldAppFeature unit-tests (DX-2).
2
+
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
7
+ import { scaffoldApp } from "../scaffold-app";
8
+ import { scaffoldAppFeature } from "../scaffold-app-feature";
9
+
10
+ describe("scaffoldAppFeature", () => {
11
+ let tmp: string;
12
+ let appRoot: string;
13
+ beforeEach(() => {
14
+ tmp = mkdtempSync(join(tmpdir(), "scaffold-app-feature-"));
15
+ appRoot = join(tmp, "my-shop");
16
+ scaffoldApp({ name: "my-shop", destination: appRoot });
17
+ });
18
+ afterEach(() => {
19
+ rmSync(tmp, { recursive: true, force: true });
20
+ });
21
+
22
+ test("scaffolds src/features/<name>/feature.ts + index.ts", () => {
23
+ const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
24
+ expect(result.featureName).toBe("product-catalog");
25
+ expect(result.files).toEqual([
26
+ "src/features/product-catalog/feature.ts",
27
+ "src/features/product-catalog/index.ts",
28
+ ]);
29
+ expect(existsSync(join(appRoot, "src/features/product-catalog/feature.ts"))).toBe(true);
30
+ expect(existsSync(join(appRoot, "src/features/product-catalog/index.ts"))).toBe(true);
31
+ });
32
+
33
+ test("feature.ts uses kebab-name + camelCase variable", () => {
34
+ scaffoldAppFeature({ name: "product-catalog", appRoot });
35
+ const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
36
+ expect(feature).toContain(`defineFeature("product-catalog"`);
37
+ expect(feature).toContain("export const productCatalogFeature");
38
+ expect(feature).toContain('r.entity("product-catalog-item"');
39
+ });
40
+
41
+ test("auto-mounts in src/run-config.ts (import + APP_FEATURES entry)", () => {
42
+ const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
43
+ expect(result.autoMounted).toBe(true);
44
+ const runConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
45
+ expect(runConfig).toContain(
46
+ `import { productCatalogFeature } from "./features/product-catalog";`,
47
+ );
48
+ expect(runConfig).toContain("productCatalogFeature");
49
+ // APP_FEATURES still ends with `as const`
50
+ expect(runConfig).toMatch(/\]\s*as const;/);
51
+ });
52
+
53
+ test("idempotent: re-mount of existing feature is a no-op", () => {
54
+ scaffoldAppFeature({ name: "product-catalog", appRoot });
55
+ const firstRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
56
+ // Now re-mount (second feature creates dir-already-exists error;
57
+ // we instead simulate "feature dir exists, only run-config dance").
58
+ // → Real DX-2 flow: scaffold fails on dir-exists; manual remount
59
+ // would call mountInRunConfig directly. Test the mount-side
60
+ // idempotency by triggering a second feature with a different
61
+ // name and asserting the first import stays exactly once.
62
+ scaffoldAppFeature({ name: "billing", appRoot });
63
+ const secondRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
64
+ expect(secondRunConfig).toContain("productCatalogFeature");
65
+ expect(secondRunConfig).toContain("billingFeature");
66
+ // Count: each feature-import appears exactly once.
67
+ const occurrences = secondRunConfig.match(/productCatalogFeature/g) ?? [];
68
+ expect(occurrences.length).toBe(2); // 1 import + 1 array-entry
69
+ expect(firstRunConfig.length).toBeLessThan(secondRunConfig.length);
70
+ });
71
+
72
+ test("rejects non-kebab-case", () => {
73
+ expect(() => scaffoldAppFeature({ name: "ProductCatalog", appRoot })).toThrow(/kebab-case/);
74
+ expect(() => scaffoldAppFeature({ name: "product_catalog", appRoot })).toThrow(/kebab-case/);
75
+ });
76
+
77
+ test("refuses to overwrite existing feature dir", () => {
78
+ scaffoldAppFeature({ name: "billing", appRoot });
79
+ expect(() => scaffoldAppFeature({ name: "billing", appRoot })).toThrow(/already exists/);
80
+ });
81
+
82
+ test("autoMounted=false when run-config.ts is missing", () => {
83
+ const emptyRoot = join(tmp, "no-app");
84
+ expect(() => scaffoldAppFeature({ name: "foo", appRoot: emptyRoot })).not.toThrow();
85
+ const result = scaffoldAppFeature({ name: "bar", appRoot: emptyRoot });
86
+ expect(result.autoMounted).toBe(false);
87
+ });
88
+ });
@@ -0,0 +1,104 @@
1
+ // scaffoldApp unit-tests (DX-1.0).
2
+
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
7
+ import { scaffoldApp } from "../scaffold-app";
8
+
9
+ describe("scaffoldApp", () => {
10
+ let tmp: string;
11
+ beforeEach(() => {
12
+ tmp = mkdtempSync(join(tmpdir(), "scaffold-app-"));
13
+ });
14
+ afterEach(() => {
15
+ rmSync(tmp, { recursive: true, force: true });
16
+ });
17
+
18
+ test("scaffolds 6 files into <cwd>/<name>", () => {
19
+ const dest = join(tmp, "my-shop");
20
+ const result = scaffoldApp({ name: "my-shop", destination: dest });
21
+
22
+ expect(result.appName).toBe("my-shop");
23
+ 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
+ ".env.example",
30
+ "README.md",
31
+ ]);
32
+ for (const f of result.files) {
33
+ expect(existsSync(join(dest, f))).toBe(true);
34
+ }
35
+ });
36
+
37
+ test("package.json has @cosmicdrift/* deps with version pin", () => {
38
+ const dest = join(tmp, "my-shop");
39
+ scaffoldApp({ name: "my-shop", destination: dest, frameworkVersion: "^0.13.0" });
40
+
41
+ const pkg = JSON.parse(readFileSync(join(dest, "package.json"), "utf-8")) as {
42
+ name: string;
43
+ dependencies: Record<string, string>;
44
+ scripts: Record<string, string>;
45
+ };
46
+ expect(pkg.name).toBe("my-shop");
47
+ expect(pkg.dependencies["@cosmicdrift/kumiko-bundled-features"]).toBe("^0.13.0");
48
+ expect(pkg.dependencies["@cosmicdrift/kumiko-dev-server"]).toBe("^0.13.0");
49
+ expect(pkg.dependencies["@cosmicdrift/kumiko-framework"]).toBe("^0.13.0");
50
+ expect(pkg.scripts["boot"]).toContain("KUMIKO_DRY_RUN_ENV=boot");
51
+ });
52
+
53
+ test("bin/main.ts contains runProdApp + auth.admin stub", () => {
54
+ const dest = join(tmp, "my-shop");
55
+ scaffoldApp({ name: "my-shop", destination: dest });
56
+
57
+ const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
58
+ expect(main).toContain("runProdApp");
59
+ expect(main).toContain("auth: {");
60
+ expect(main).toContain("admin@my-shop.local");
61
+ expect(main).toContain('tenantKey: "my-shop"');
62
+ // Tenant-ID is a valid UUID-v4 format (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx).
63
+ 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}/);
64
+ });
65
+
66
+ test("src/run-config.ts mounts secrets + sessions as foundation", () => {
67
+ const dest = join(tmp, "my-shop");
68
+ scaffoldApp({ name: "my-shop", destination: dest });
69
+
70
+ const runConfig = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
71
+ expect(runConfig).toContain("createSecretsFeature()");
72
+ expect(runConfig).toContain("createSessionsFeature()");
73
+ expect(runConfig).toContain("export const APP_FEATURES");
74
+ });
75
+
76
+ test("rejects non-kebab-case names", () => {
77
+ expect(() => scaffoldApp({ name: "MyShop", destination: tmp })).toThrow(/kebab-case/);
78
+ expect(() => scaffoldApp({ name: "my_shop", destination: tmp })).toThrow(/kebab-case/);
79
+ expect(() => scaffoldApp({ name: "0shop", destination: tmp })).toThrow(/kebab-case/);
80
+ });
81
+
82
+ test("refuses to overwrite existing destination", () => {
83
+ const dest = join(tmp, "existing");
84
+ scaffoldApp({ name: "existing", destination: dest });
85
+ expect(() => scaffoldApp({ name: "existing", destination: dest })).toThrow(/already exists/);
86
+ });
87
+
88
+ test("deterministic tenantId for same name (reproducible boots)", () => {
89
+ const a = join(tmp, "a");
90
+ const b = join(tmp, "b");
91
+ scaffoldApp({ name: "stable", destination: a });
92
+ scaffoldApp({ name: "stable", destination: b });
93
+ const mainA = readFileSync(join(a, "bin/main.ts"), "utf-8");
94
+ const mainB = readFileSync(join(b, "bin/main.ts"), "utf-8");
95
+ const uuidA = mainA.match(
96
+ /[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/,
97
+ )?.[0];
98
+ const uuidB = mainB.match(
99
+ /[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/,
100
+ )?.[0];
101
+ expect(uuidA).toBeDefined();
102
+ expect(uuidA).toBe(uuidB);
103
+ });
104
+ });
@@ -56,6 +56,17 @@ describe("scaffoldDeploy", () => {
56
56
  expect(migrate).toContain("ghcr.io/cosmicdriftgamestudio/minimal:latest");
57
57
  });
58
58
 
59
+ it("Dockerfile emits inline start.sh (createBunServer command-override target)", () => {
60
+ scaffoldDeploy({ appName: "boot-target", destination: tmp });
61
+ const dockerfile = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
62
+ // Inline RUN that creates a start.sh inside the runtime image.
63
+ // bun-server.ts's createBunServer overrides the container command with
64
+ // `exec ./start.sh` after injecting DATABASE_URL; without this line the
65
+ // pod exited 127. Memory: `feedback_audit_drift_root_cause_now`.
66
+ expect(dockerfile).toContain("> ./start.sh && chmod +x ./start.sh");
67
+ expect(dockerfile).toContain("exec bun run server.js");
68
+ });
69
+
59
70
  it("skips existing files by default", () => {
60
71
  const existing = join(tmp, "deploy");
61
72
  scaffoldDeploy({ appName: "first", destination: tmp });
@@ -140,6 +151,7 @@ describe("scaffoldDeploy", () => {
140
151
  const df = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
141
152
  expect(df).toContain("ARG GITHUB_TOKEN=");
142
153
  expect(df).toContain("ARG GITHUB_TOKEN\n");
154
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: shell variable expansion, not a JS template
143
155
  expect(df).toContain("ENV GITHUB_TOKEN=${GITHUB_TOKEN}");
144
156
  });
145
157
 
@@ -82,7 +82,25 @@ const RUNTIME_EXTERNALS = [
82
82
  // Markierung scheitert bun build an dynamic-imports (z.B. drizzle-kit →
83
83
  // @libsql/client). Tree-Shake wirft sie eh aus dem Bundle — der Marker
84
84
  // schaltet nur das resolution-during-build ab. NICHT in runtime-deps.
85
- const BUILD_ONLY_EXTERNALS = ["meilisearch", "pino", "pino-pretty", "@aws-sdk/*"] as const;
85
+ //
86
+ // drizzle-kit's dialect-resolver macht dynamic-imports zu allen DB-driver-
87
+ // packages (planetscale/libsql/sqlite/neon/vercel/mysql2). Wir nutzen nur
88
+ // postgres → diese werden never-loaded zur Runtime, aber der Bundler will
89
+ // sie resolven. Aufgedeckt durch C1 Empfehlung 4 (bundle-smoke).
90
+ const BUILD_ONLY_EXTERNALS = [
91
+ "meilisearch",
92
+ "pino",
93
+ "pino-pretty",
94
+ "@aws-sdk/*",
95
+ "@planetscale/database",
96
+ "@libsql/client",
97
+ "better-sqlite3",
98
+ "@neondatabase/serverless",
99
+ "@vercel/postgres",
100
+ "mysql2",
101
+ // ink (kumiko-tui) hat react-devtools-core als dev-only transitive import.
102
+ "react-devtools-core",
103
+ ] as const;
86
104
 
87
105
  export type BuildServerBundleOptions = {
88
106
  /** App-Root. Default: process.cwd(). */
package/src/index.ts CHANGED
@@ -54,6 +54,13 @@ export type {
54
54
  SignupSetup,
55
55
  } from "./run-prod-app";
56
56
  export { runProdApp } from "./run-prod-app";
57
+ export type { ScaffoldAppOptions, ScaffoldAppResult } from "./scaffold-app";
58
+ export { scaffoldApp } from "./scaffold-app";
59
+ export type {
60
+ ScaffoldAppFeatureOptions,
61
+ ScaffoldAppFeatureResult,
62
+ } from "./scaffold-app-feature";
63
+ export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature";
57
64
  export type {
58
65
  ScaffoldDeployOptions,
59
66
  ScaffoldDeployResult,
@@ -126,23 +126,28 @@ function readEnv(name: string): string | undefined {
126
126
  return value === undefined || value === "" ? undefined : value;
127
127
  }
128
128
 
129
- // Parse `KUMIKO_DRY_RUN_ENV=…` into a DryRunMode. Truthy "1" aliases
130
- // "human" the most common deploy-Q quick-look. Unknown values are
131
- // warned-and-ignored: a typo like `=humans` would otherwise look like
132
- // a confused boot 30 seconds later when the schema fails.
133
- function parseDryRunMode(raw: string | undefined): DryRunMode | null {
129
+ // `boot` is the C1 smoke-test path — validators run, no DB/Redis connect,
130
+ // exit after registry-build. Render-modes (human|json|pulumi|k8s|1)
131
+ // inspect the env-schema and exit before any feature wiring.
132
+ type RunMode = DryRunMode | "boot";
133
+
134
+ function parseRunMode(raw: string | undefined): RunMode | null {
134
135
  if (!raw) return null;
135
136
  const v = raw.toLowerCase();
136
137
  if (v === "1" || v === "true" || v === "human") return "human";
137
- if (v === "json" || v === "pulumi" || v === "k8s") return v;
138
+ if (v === "json" || v === "pulumi" || v === "k8s" || v === "boot") return v;
138
139
  // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
139
140
  console.warn(
140
141
  `[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
141
- `(expected 1|human|json|pulumi|k8s); continuing with normal boot.`,
142
+ `(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`,
142
143
  );
143
144
  return null;
144
145
  }
145
146
 
147
+ function isRenderMode(mode: RunMode | null): mode is DryRunMode {
148
+ return mode !== null && mode !== "boot";
149
+ }
150
+
146
151
  function defaultBootErrorReporter(err: KumikoBootError): never {
147
152
  // biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
148
153
  console.error(err.format());
@@ -485,13 +490,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
485
490
  // at parse-time before the polyfill loads. Plain strings + .regex /
486
491
  // .min / .email / .url cover every env-var shape we've actually
487
492
  // needed in 9.1's audit (37 references, 25 distinct vars).
493
+ const envSource = options.envSource ?? process.env;
494
+ const runMode = parseRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
488
495
  if (options.envSchema) {
489
- const envSource = options.envSource ?? process.env;
490
- const dryRunMode = parseDryRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
491
- if (dryRunMode !== null) {
496
+ if (isRenderMode(runMode)) {
492
497
  // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
493
498
  console.log(
494
- renderDryRun(options.envSchema, dryRunMode, {
499
+ renderDryRun(options.envSchema, runMode, {
495
500
  ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
496
501
  sources: options.envSchema.sources,
497
502
  }),
@@ -504,6 +509,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
504
509
  }
505
510
  return makeDryRunHandle();
506
511
  }
512
+ // boot-mode AND normal-boot both run env-validation. boot-mode wants
513
+ // a real env-check (all required vars present + schema-valid) before
514
+ // it asserts feature-wiring works.
507
515
  try {
508
516
  parseEnv(options.envSchema.schema, envSource, {
509
517
  sources: options.envSchema.sources,
@@ -554,6 +562,22 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
554
562
  validateBoot(features);
555
563
  const registry = createRegistry(features);
556
564
 
565
+ // C1 boot-mode exit: validators ran, registry built, no DB/Redis
566
+ // operations executed yet (postgres.js + ioredis are lazy). Tear down
567
+ // the lazy clients so Bun doesn't keep them open, then exit / return.
568
+ if (runMode === "boot") {
569
+ // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
570
+ console.log(
571
+ `[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
572
+ );
573
+ await closeDb();
574
+ redis.disconnect();
575
+ if (options.envSource === undefined) {
576
+ process.exit(0);
577
+ }
578
+ return makeDryRunHandle();
579
+ }
580
+
557
581
  // Sprint-8a Tier-Composition auto-wire: scan features for a
558
582
  // tenantTierResolver-extension. If found AND user didn't supply own
559
583
  // effectiveFeatures, build the resolver here (db + registry are
@@ -0,0 +1,154 @@
1
+ // scaffoldAppFeature — DX-2. Scaffolds a fresh feature inside an
2
+ // existing Kumiko-app workspace + auto-mounts it in src/run-config.ts.
3
+ //
4
+ // Sister to `scaffoldFeature` (which targets samples/recipes/ for the
5
+ // framework workspace). This one targets `src/features/<name>/` of an
6
+ // already-scaffolded app (output of `kumiko new app`).
7
+ //
8
+ // Auto-mount via ts-morph: opens src/run-config.ts, finds
9
+ // `export const APP_FEATURES = [...]`, prepends import + appends entry.
10
+ // User's promise "defineFeature → nichts woanders eintragen" is met
11
+ // for the run-config side. Drizzle FEATURE_IMPORT_REGISTRY is NOT
12
+ // touched here — DX-4 auto-discovery resolves that.
13
+
14
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+ import { Project, SyntaxKind } from "ts-morph";
17
+
18
+ export type ScaffoldAppFeatureOptions = {
19
+ /** kebab-case feature name (e.g. "product-catalog"). */
20
+ readonly name: string;
21
+ /** App workspace root. Defaults to cwd. */
22
+ readonly appRoot?: string;
23
+ };
24
+
25
+ export type ScaffoldAppFeatureResult = {
26
+ readonly featureName: string;
27
+ readonly featureDir: string;
28
+ readonly files: readonly string[];
29
+ /** Whether src/run-config.ts was auto-mounted. False if run-config
30
+ * is missing — caller gets the scaffolded files but must hand-mount. */
31
+ readonly autoMounted: boolean;
32
+ };
33
+
34
+ const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
35
+
36
+ export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): ScaffoldAppFeatureResult {
37
+ if (!KEBAB_RE.test(options.name)) {
38
+ throw new Error(
39
+ `scaffoldAppFeature: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`,
40
+ );
41
+ }
42
+ const appRoot = resolve(options.appRoot ?? process.cwd());
43
+ const featureDir = join(appRoot, "src", "features", options.name);
44
+ if (existsSync(featureDir)) {
45
+ throw new Error(`scaffoldAppFeature: ${featureDir} already exists — refusing to overwrite`);
46
+ }
47
+ mkdirSync(featureDir, { recursive: true });
48
+
49
+ const files: string[] = [];
50
+ const featureFile = join(featureDir, "feature.ts");
51
+ writeFileSync(featureFile, renderFeature(options.name));
52
+ files.push(`src/features/${options.name}/feature.ts`);
53
+
54
+ const indexFile = join(featureDir, "index.ts");
55
+ writeFileSync(indexFile, renderIndex(options.name));
56
+ files.push(`src/features/${options.name}/index.ts`);
57
+
58
+ const runConfigPath = join(appRoot, "src", "run-config.ts");
59
+ const autoMounted = existsSync(runConfigPath)
60
+ ? mountInRunConfig(runConfigPath, options.name)
61
+ : false;
62
+
63
+ return {
64
+ featureName: options.name,
65
+ featureDir,
66
+ files,
67
+ autoMounted,
68
+ };
69
+ }
70
+
71
+ function renderFeature(name: string): string {
72
+ const camel = kebabToCamel(name);
73
+ return `// ${name} feature — scaffolded by \`kumiko add feature\`. Edit freely.
74
+ //
75
+ // Doc-Pointer: https://docs.kumiko.so/en/patterns/ for the \`r.*\` API
76
+ // (r.entity, r.writeHandler, r.queryHandler, hooks, …).
77
+
78
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
79
+
80
+ export const ${camel}Feature = defineFeature("${name}", (r) => {
81
+ // Starter: declare an entity. Drop and replace with your domain.
82
+ r.entity("${name}-item", {
83
+ fields: {
84
+ title: { type: "text", required: true },
85
+ },
86
+ });
87
+ });
88
+ `;
89
+ }
90
+
91
+ function renderIndex(name: string): string {
92
+ const camel = kebabToCamel(name);
93
+ return `export { ${camel}Feature } from "./feature";\n`;
94
+ }
95
+
96
+ function kebabToCamel(name: string): string {
97
+ return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
98
+ }
99
+
100
+ // ts-morph: open run-config, prepend import, append APP_FEATURES entry.
101
+ // Returns true on success, throws on shape-mismatch (caller swallows the
102
+ // scaffolded files but warns).
103
+ function mountInRunConfig(runConfigPath: string, name: string): boolean {
104
+ const camel = kebabToCamel(name);
105
+ const project = new Project({
106
+ skipAddingFilesFromTsConfig: true,
107
+ skipFileDependencyResolution: true,
108
+ });
109
+ const sf = project.addSourceFileAtPath(runConfigPath);
110
+
111
+ // Already mounted? short-circuit (idempotent re-runs).
112
+ const existingImport = sf.getImportDeclaration(`./features/${name}`);
113
+ if (existingImport) return true;
114
+
115
+ // 1. Prepend import after the last existing import.
116
+ const imports = sf.getImportDeclarations();
117
+ const insertIndex = imports.length > 0 ? imports[imports.length - 1]!.getChildIndex() + 1 : 0;
118
+ sf.insertImportDeclaration(insertIndex, {
119
+ moduleSpecifier: `./features/${name}`,
120
+ namedImports: [`${camel}Feature`],
121
+ });
122
+
123
+ // 2. Find `export const APP_FEATURES = [...]` and append the new entry.
124
+ const appFeaturesDecl = sf.getVariableDeclaration("APP_FEATURES");
125
+ if (!appFeaturesDecl) {
126
+ throw new Error(
127
+ `mountInRunConfig: ${runConfigPath} has no 'APP_FEATURES' declaration — ` +
128
+ `cannot auto-mount. Hand-edit: add '${camel}Feature' to APP_FEATURES.`,
129
+ );
130
+ }
131
+ const initializer =
132
+ appFeaturesDecl.getInitializerIfKind(SyntaxKind.AsExpression) ??
133
+ appFeaturesDecl.getInitializer();
134
+ if (!initializer) {
135
+ throw new Error(`mountInRunConfig: APP_FEATURES has no initializer — cannot auto-mount.`);
136
+ }
137
+ // Strip `as const` wrapper if present.
138
+ const arr =
139
+ initializer.getKind() === SyntaxKind.AsExpression
140
+ ? initializer.getFirstChildByKind(SyntaxKind.ArrayLiteralExpression)
141
+ : initializer.asKind(SyntaxKind.ArrayLiteralExpression);
142
+ if (!arr) {
143
+ throw new Error(`mountInRunConfig: APP_FEATURES is not an array literal — cannot auto-mount.`);
144
+ }
145
+ arr.addElement(`${camel}Feature`);
146
+
147
+ sf.saveSync();
148
+ return true;
149
+ }
150
+
151
+ // Re-export so consumers can hint at the file (e.g. for kumiko-cli output).
152
+ export function runConfigPathForApp(appRoot: string): string {
153
+ return join(appRoot, "src", "run-config.ts");
154
+ }
@@ -0,0 +1,270 @@
1
+ // scaffoldApp — generate a runnable Kumiko app workspace from a name.
2
+ //
3
+ // Used by `kumiko new app <name>`. Produces the minimal app shape that
4
+ // `KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts` runs successfully against:
5
+ // run-config with 5 foundation features, bin/main.ts with auth-admin
6
+ // stub, package.json with @cosmicdrift/* deps, tsconfig, .env.example,
7
+ // README.
8
+ //
9
+ // Intentionally NOT included in DX-1.0:
10
+ // - drizzle/ setup (DX-1.1 — needs FEATURE_IMPORT_REGISTRY decision from DX-4)
11
+ // - deploy/Dockerfile (already covered by scaffoldDeploy — separate cmd)
12
+ // - first feature scaffold (use scaffoldFeature after this)
13
+ //
14
+ // The generated app is born "boots cleanly, mounts nothing fancy". User
15
+ // runs `kumiko add feature` (DX-2) or hand-edits src/run-config.ts to grow.
16
+
17
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
+ import { join, resolve } from "node:path";
19
+
20
+ export type ScaffoldAppOptions = {
21
+ /** kebab-case app name (e.g. "my-shop"). Becomes package-name + folder. */
22
+ readonly name: string;
23
+ /** Absolute or cwd-relative target dir. Default: <cwd>/<name>. */
24
+ readonly destination?: string;
25
+ /** npm-version-pin for @cosmicdrift/* deps. Default "*" for latest. */
26
+ readonly frameworkVersion?: string;
27
+ };
28
+
29
+ export type ScaffoldAppResult = {
30
+ readonly destination: string;
31
+ readonly files: readonly string[];
32
+ readonly appName: string;
33
+ };
34
+
35
+ const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
36
+
37
+ export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
38
+ if (!KEBAB_RE.test(options.name)) {
39
+ throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
40
+ }
41
+ const cwd = process.cwd();
42
+ const destination = resolve(cwd, options.destination ?? options.name);
43
+ if (existsSync(destination)) {
44
+ throw new Error(`scaffoldApp: ${destination} already exists — refusing to overwrite`);
45
+ }
46
+ const version = options.frameworkVersion ?? "*";
47
+
48
+ mkdirSync(join(destination, "bin"), { recursive: true });
49
+ mkdirSync(join(destination, "src"), { recursive: true });
50
+
51
+ const files: string[] = [];
52
+
53
+ write(join(destination, "package.json"), renderPackageJson(options.name, version));
54
+ files.push("package.json");
55
+
56
+ write(join(destination, "tsconfig.json"), renderTsconfig());
57
+ files.push("tsconfig.json");
58
+
59
+ write(join(destination, "src", "run-config.ts"), renderRunConfig());
60
+ files.push("src/run-config.ts");
61
+
62
+ write(join(destination, "bin", "main.ts"), renderMain(options.name));
63
+ files.push("bin/main.ts");
64
+
65
+ write(join(destination, ".env.example"), renderEnvExample());
66
+ files.push(".env.example");
67
+
68
+ write(join(destination, "README.md"), renderReadme(options.name));
69
+ files.push("README.md");
70
+
71
+ return { destination, files, appName: options.name };
72
+ }
73
+
74
+ function write(path: string, content: string): void {
75
+ writeFileSync(path, content);
76
+ }
77
+
78
+ function renderPackageJson(name: string, version: string): string {
79
+ return `${JSON.stringify(
80
+ {
81
+ name,
82
+ version: "0.1.0",
83
+ private: true,
84
+ type: "module",
85
+ scripts: {
86
+ boot: "KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts",
87
+ check: "tsc --noEmit",
88
+ },
89
+ dependencies: {
90
+ "@cosmicdrift/kumiko-bundled-features": version,
91
+ "@cosmicdrift/kumiko-dev-server": version,
92
+ "@cosmicdrift/kumiko-framework": version,
93
+ zod: "^4.4.3",
94
+ },
95
+ },
96
+ null,
97
+ 2,
98
+ )}\n`;
99
+ }
100
+
101
+ function renderTsconfig(): string {
102
+ return `${JSON.stringify(
103
+ {
104
+ compilerOptions: {
105
+ strict: true,
106
+ noUncheckedIndexedAccess: true,
107
+ forceConsistentCasingInFileNames: true,
108
+ verbatimModuleSyntax: true,
109
+ target: "ESNext",
110
+ module: "ESNext",
111
+ moduleResolution: "bundler",
112
+ esModuleInterop: true,
113
+ skipLibCheck: true,
114
+ lib: ["ESNext"],
115
+ types: ["bun-types"],
116
+ noEmit: true,
117
+ },
118
+ include: ["bin", "src"],
119
+ },
120
+ null,
121
+ 2,
122
+ )}\n`;
123
+ }
124
+
125
+ function renderRunConfig(): string {
126
+ return `// Single source of truth für die Feature-Komposition deiner App.
127
+ // Bundled-Foundation: secrets + sessions. config/user/tenant/auth-email-password
128
+ // werden via composeFeatures(includeBundled:true) automatisch ergänzt
129
+ // wenn runProdApp mit \`auth: {…}\` aufgerufen wird (siehe bin/main.ts).
130
+ //
131
+ // Neue features hinzufügen:
132
+ // - bunx kumiko add feature <name> (DX-2, automatisch)
133
+ // - oder: hand-edit + import unten ergänzen
134
+
135
+ import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
136
+ import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
137
+
138
+ export const APP_FEATURES = [
139
+ createSecretsFeature(),
140
+ createSessionsFeature(),
141
+ ] as const;
142
+ `;
143
+ }
144
+
145
+ function renderMain(appName: string): string {
146
+ // Deterministic tenant-UUID derived from appName for the seed-admin
147
+ // membership. Reproducible across boots; tenants table sees the same
148
+ // ID. Format: 8-4-4-4-12 hex chars, version-4 marker at position 14.
149
+ // We hash the name into the digits using a tiny PRNG so two apps
150
+ // get different IDs without bun's crypto dependency.
151
+ const tenantId = deriveTenantId(appName);
152
+ return `// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
153
+ // composeFeatures + validateBoot + createRegistry without DB/Redis-connect
154
+ // (siehe @cosmicdrift/kumiko-dev-server runProdApp). Echter Dev-Boot
155
+ // passiert via \`bunx kumiko dev\` mit Docker-stack — DX-1.0 deckt nur
156
+ // den boot-mode-Pfad ab; \`kumiko dev\` kommt in einer späteren DX-Phase.
157
+
158
+ import { frameworkCoreEnvSchema, runProdApp } from "@cosmicdrift/kumiko-dev-server";
159
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
160
+ import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
161
+ import { APP_FEATURES } from "../src/run-config";
162
+
163
+ const DEFAULT_TENANT_ID = "${tenantId}" as TenantId;
164
+
165
+ const envSchema = composeEnvSchema({
166
+ core: frameworkCoreEnvSchema,
167
+ features: APP_FEATURES,
168
+ });
169
+
170
+ await runProdApp({
171
+ features: APP_FEATURES,
172
+ envSchema,
173
+ migrations: false,
174
+ auth: {
175
+ admin: {
176
+ email: "admin@${appName}.local",
177
+ password: "change-me-on-first-deploy",
178
+ displayName: "Admin",
179
+ memberships: [
180
+ {
181
+ tenantId: DEFAULT_TENANT_ID,
182
+ tenantKey: "${appName}",
183
+ tenantName: "${appName}",
184
+ roles: ["TenantAdmin"],
185
+ },
186
+ ],
187
+ },
188
+ },
189
+ });
190
+ `;
191
+ }
192
+
193
+ function renderEnvExample(): string {
194
+ return `# Required env-vars für boot-mode + dev. Production: über Pulumi/k8s-Secrets.
195
+ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/app
196
+ REDIS_URL=redis://127.0.0.1:6379
197
+
198
+ # JWT_SECRET: min 32 chars. Generate with: openssl rand -base64 32
199
+ JWT_SECRET=change-me-min-32-chars-change-me-min-32
200
+
201
+ # KUMIKO_SECRETS_MASTER_KEY_V1: base64-encoded 32 bytes (AES-256 KEK).
202
+ # Generate with: openssl rand -base64 32
203
+ KUMIKO_SECRETS_MASTER_KEY_V1=
204
+ `;
205
+ }
206
+
207
+ function renderReadme(appName: string): string {
208
+ return `# ${appName}
209
+
210
+ Scaffolded by \`kumiko new app\`. Boots out-of-the-box with secrets + sessions
211
+ mounted (foundation set). Add features with \`bunx kumiko add feature <name>\`.
212
+
213
+ ## First boot
214
+
215
+ \`\`\`sh
216
+ yarn install
217
+ cp .env.example .env
218
+ # edit .env — set JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1
219
+ bun run boot
220
+ \`\`\`
221
+
222
+ Expected: \`[runProdApp] boot validation OK (… features, … registry entries)\` + exit 0.
223
+
224
+ ## Adding features
225
+
226
+ \`\`\`sh
227
+ bunx kumiko add feature my-domain
228
+ # → editiert src/run-config.ts automatisch + scaffolded src/features/my-domain/
229
+ \`\`\`
230
+
231
+ ## Architecture
232
+
233
+ - \`src/run-config.ts\` — single source of truth: which features your app mounts.
234
+ - \`bin/main.ts\` — production-bootstrap. Reads env, mounts features, starts server.
235
+
236
+ For full docs see https://docs.kumiko.so.
237
+ `;
238
+ }
239
+
240
+ // Deterministic tenant-ID from app-name. Format: UUID-v4 with the
241
+ // version-marker at the right spot. NOT cryptographically random —
242
+ // just a stable per-app default the user can change later.
243
+ function deriveTenantId(name: string): string {
244
+ // Tiny xorshift PRNG seeded from the name's char-codes. Same name →
245
+ // same ID. Sufficient for "give every scaffolded app a deterministic
246
+ // default tenant" — production sets its own via the create-tenant
247
+ // flow anyway.
248
+ let state = 2166136261;
249
+ for (const ch of name) {
250
+ state ^= ch.charCodeAt(0);
251
+ state = Math.imul(state, 16777619) >>> 0;
252
+ }
253
+ const hex = (n: number, len: number): string => n.toString(16).padStart(len, "0").slice(0, len);
254
+ const a = hex(state, 8);
255
+ state ^= state << 13;
256
+ state >>>= 0;
257
+ const b = hex(state, 4);
258
+ // version-4 marker at first char of 3rd group:
259
+ state ^= state >>> 17;
260
+ state >>>= 0;
261
+ const c = `4${hex(state, 3)}`;
262
+ // RFC 4122 variant: 10xx (set top two bits of 4th group to 10):
263
+ state ^= state << 5;
264
+ state >>>= 0;
265
+ const d4 = (0x8 | (state & 0x3)).toString(16);
266
+ const d = `${d4}${hex(state >>> 4, 3)}`;
267
+ state = Math.imul(state, 16777619) >>> 0;
268
+ const e = hex(state, 12);
269
+ return `${a}-${b}-${c}-${d}-${e}`;
270
+ }
@@ -58,6 +58,15 @@ COPY package.json yarn.lock .yarnrc.yml ./
58
58
  # bin/kumiko-build.ts), which writes .kumiko/define.ts and turns the
59
59
  # symlink real before the bundle is built.
60
60
  ENV YARN_ENABLE_INLINE_BUILDS=true
61
+ # Skip postinstall scripts for ALL deps in the build stage. Reason:
62
+ # `bun build` bundles JS source only — no native bindings needed at bundle-
63
+ # time. msgpackr-extract is the most common offender (ARM/CI native-build
64
+ # failures), but the rule applies broadly: any native dep loaded at runtime
65
+ # gets re-installed via `bun install --production` in the runtime stage,
66
+ # which uses bun's own postinstall handling. Apps that needed per-package
67
+ # opt-outs via `dependenciesMeta.<pkg>.built=false` in package.json (e.g.
68
+ # studio, enterprise) can remove those entries after adopting this template.
69
+ ENV YARN_ENABLE_SCRIPTS=false
61
70
  {{#hasPrivateGhPackages}}
62
71
  # Re-export GITHUB_TOKEN as env so yarn-4's `${GITHUB_TOKEN:-…}` expansion
63
72
  # in .yarnrc.yml finds it during the install step.
@@ -89,6 +98,14 @@ COPY --from=build --chown=app:app /app/dist ./dist
89
98
  COPY --from=build --chown=app:app /app/dist-server/drizzle.config.ts ./drizzle.config.ts
90
99
  COPY --from=build --chown=app:app /app/drizzle ./drizzle
91
100
 
101
+ # Container entrypoint — `infra/pulumi/bun-server.ts` overrides the container
102
+ # command to inject DATABASE_URL from the init-container's /shared/database-url
103
+ # then execs `./start.sh`. We generate it inline so app-source-roots stay
104
+ # clean (no per-app start.sh duplication). Apps that don't go through
105
+ # createBunServer's command-override still boot via the CMD at the bottom
106
+ # (`exec bun run server.js`) — start.sh is dead-code in that case.
107
+ RUN printf '#!/bin/sh\nset -e\nexec bun run server.js\n' > ./start.sh && chmod +x ./start.sh
108
+
92
109
  {{#hasSeeds}}
93
110
  # ES-Operations seed migrations — runtime-loaded via dynamic import. Bun
94
111
  # does NOT bundle these into dist-server/server.js (await import on a