@cosmicdrift/kumiko-dev-server 0.12.2 → 0.14.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,129 @@
1
1
  # @cosmicdrift/kumiko-dev-server
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b8e1d48: scaffoldApp baut `src/run-config.ts` + `bin/main.ts` jetzt via ts-morph
8
+ (AST) statt template-strings. Selbes Tool wie scaffoldAppFeature →
9
+ ein konsistenter Mechanismus für generate + later modify. Plus:
10
+ ts-morph als explicit dependency aufgenommen (war bisher nur via
11
+ hoisted root-dep verfügbar; broken bei publish).
12
+
13
+ ### Patch Changes
14
+
15
+ - ce23d48: `walkthrough.integration.ts` — DX-3.1 walkthrough-snapshot-test. Pins
16
+ scaffoldApp + scaffoldAppFeature output gegen die Behauptungen in
17
+ docs.kumiko.so/en/walkthrough/. Catches doc-drift ohne actual
18
+ `bunx … && yarn install && bun run boot` CI-run.
19
+
20
+ 5 Tests: file-list, auto-mount-diff, run-config text-content,
21
+ composeFeatures(includeBundled:true) = 7 features, bin/main auth.admin
22
+ stub.
23
+
24
+ - @cosmicdrift/kumiko-framework@0.14.0
25
+ - @cosmicdrift/kumiko-bundled-features@0.14.0
26
+
27
+ ## 0.13.0
28
+
29
+ ### Minor Changes
30
+
31
+ - 7bd5c88: `KUMIKO_DRY_RUN_ENV=boot` mode for runProdApp — runs env-validation +
32
+ composeFeatures + validateBoot + createRegistry without DB/Redis
33
+ connect, exits with status 0 on success. Used by the
34
+ `samples/apps/use-all-bundled` smoke-app (Sprint 9.8 Phase C / Empfehlung
35
+ 1 / canonical bug-catcher) and downstream by enterprise's
36
+ `use-all-features` mirror. Render-modes (human|json|pulumi|k8s|1)
37
+ behavior unchanged.
38
+ - 575752f: `scaffoldAppFeature` + `kumiko add feature <name>` — DX-2 aus DX-Roadmap.
39
+ Scaffolded ein neues Feature in `src/features/<name>/` einer bereits via
40
+ `kumiko new app` scaffolded App + **auto-mountet** es in `src/run-config.ts`
41
+ via ts-morph (import + `APP_FEATURES`-array-entry, idempotent).
42
+
43
+ User-Promise "defineFeature → nichts woanders eintragen" erfüllt für die
44
+ run-config-Seite. FEATURE_IMPORT_REGISTRY in drizzle/generate.ts ist
45
+ DX-4's Refactor — bei DX-1+DX-2-App noch nicht vorhanden.
46
+
47
+ Usage (in einer DX-1-gescaffoldeten App):
48
+
49
+ ```sh
50
+ bunx kumiko add feature product-catalog
51
+ # → src/features/product-catalog/{feature.ts,index.ts}
52
+ # → src/run-config.ts auto-edited: import + APP_FEATURES-entry
53
+ ```
54
+
55
+ - 3d5e9ef: `kumiko-schema-check` CLI — Empfehlung 3 aus Sprint-9.8-Retro
56
+ (`luminous-watching-moler.md`). Diff't APP_FEATURES (runtime, aus
57
+ `src/run-config.ts`) gegen FEATURE_IMPORT_REGISTRY (statisch, aus
58
+ `drizzle/generate.ts`). Fängt Studio's 9.8-Drama: registry 18 features
59
+ hinter APP_FEATURES → migrations fehlten für mounted features.
60
+
61
+ Usage (im app-workspace):
62
+
63
+ ```sh
64
+ bunx kumiko-schema-check
65
+ # or with custom paths:
66
+ bunx kumiko-schema-check --run-config src/run-config.ts --generate drizzle/generate.ts
67
+ ```
68
+
69
+ Plus: 5 bundled-features hatten camelCase feature-names statt kebab-case
70
+ (Memory `feedback_kebab_aggregates`) — aufgedeckt durch den schema-check
71
+ gegen use-all-bundled. Fix: `channelEmail` → `channel-email`,
72
+ `channelInApp` → `channel-in-app`, `channelPush` → `channel-push`,
73
+ `rateLimiting` → `rate-limiting`, `rendererSimple` → `renderer-simple`.
74
+
75
+ Plus `CHANNEL_IN_APP_FEATURE` und `RATE_LIMITING_FEATURE` Konstanten
76
+ angepasst (waren intern auf camelCase, jetzt kebab-case).
77
+
78
+ - 46b84d0: `scaffoldApp` + `kumiko new app <name>` — DX-1.0 aus DX-Roadmap. Generiert
79
+ ein lauffähiges App-Skelett (package.json, tsconfig, run-config mit
80
+ secrets+sessions, bin/main.ts mit auth-admin-stub + deterministische
81
+ tenant-UUID, .env.example, README) in `<cwd>/<name>/`.
82
+
83
+ Boot-Pfad: `KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts` läuft ohne DB/Redis.
84
+
85
+ Held-back für spätere DX-Phasen: drizzle-setup (DX-1.1, blocked-by DX-4
86
+ auto-registry), Dockerfile (existing `kumiko init-deploy`), first feature
87
+ scaffold (existing `kumiko create` bzw. DX-2 `kumiko add feature`).
88
+
89
+ Usage:
90
+
91
+ ```sh
92
+ bunx kumiko new app my-shop
93
+ cd my-shop && yarn install
94
+ cp .env.example .env # JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1 setzen
95
+ bun run boot # → boot validation OK
96
+ ```
97
+
98
+ ### Patch Changes
99
+
100
+ - 2bd60c1: `buildServerBundle` BUILD_ONLY_EXTERNALS erweitert um drizzle-kit's
101
+ dialect-resolver dynamic-imports: `@planetscale/database`, `@libsql/client`,
102
+ `better-sqlite3`, `@neondatabase/serverless`, `@vercel/postgres`, `mysql2`.
103
+
104
+ Aufgedeckt durch C1 Empfehlung 4 (bundle-smoke). Bisher schlug
105
+ `bun build` an dynamic-imports im drizzle-kit auch wenn der App nur
106
+ postgres nutzt. Externalisieren = build durchläuft + tree-shake wirft
107
+ die ungenutzten driver-modules eh raus.
108
+
109
+ - 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.
110
+ - cc0ddc0: `Dockerfile.template` emits an inline `start.sh` for createBunServer command-override target.
111
+
112
+ `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).
113
+
114
+ 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.
115
+
116
+ - Updated dependencies [7f56b2f]
117
+ - Updated dependencies [68b8118]
118
+ - Updated dependencies [9121928]
119
+ - Updated dependencies [72518fa]
120
+ - Updated dependencies [0a00e7b]
121
+ - Updated dependencies [aca1443]
122
+ - Updated dependencies [c6cb96c]
123
+ - Updated dependencies [3d5e9ef]
124
+ - @cosmicdrift/kumiko-framework@0.13.0
125
+ - @cosmicdrift/kumiko-bundled-features@0.13.0
126
+
3
127
  ## 0.12.2
4
128
 
5
129
  ### 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.2",
3
+ "version": "0.14.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,13 @@
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.2",
52
- "@cosmicdrift/kumiko-framework": "0.12.2"
52
+ "@cosmicdrift/kumiko-bundled-features": "0.14.0",
53
+ "@cosmicdrift/kumiko-framework": "0.14.0",
54
+ "ts-morph": "^28.0.0"
53
55
  },
54
56
  "publishConfig": {
55
57
  "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 });