@cosmicdrift/kumiko-dev-server 0.24.0 → 0.25.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.
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { existsSync, readFileSync } from "node:fs";
27
27
  import { resolve } from "node:path";
28
+ import { implicitAuthModeFeatureNames, resolveGeneratePath } from "../src/schema-check-core";
28
29
 
29
30
  type Args = {
30
31
  readonly runConfigPath: string;
@@ -34,7 +35,7 @@ type Args = {
34
35
  function parseArgs(argv: readonly string[]): Args {
35
36
  const cwd = process.cwd();
36
37
  let runConfigPath = resolve(cwd, "src/run-config.ts");
37
- let generatePath = resolve(cwd, "drizzle/generate.ts");
38
+ let generatePath: string | undefined;
38
39
  for (let i = 0; i < argv.length; i++) {
39
40
  const flag = argv[i];
40
41
  const value = argv[i + 1];
@@ -46,7 +47,7 @@ function parseArgs(argv: readonly string[]): Args {
46
47
  i++;
47
48
  }
48
49
  }
49
- return { runConfigPath, generatePath };
50
+ return { runConfigPath, generatePath: generatePath ?? resolveGeneratePath(cwd) };
50
51
  }
51
52
 
52
53
  function readRegistryFeatures(generateSrc: string): Set<string> {
@@ -74,20 +75,15 @@ async function readMountedFeatures(runConfigPath: string): Promise<Set<string>>
74
75
  `Convention: 'export const APP_FEATURES = [...] as const'.`,
75
76
  );
76
77
  }
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
78
  const set = new Set<string>();
83
79
  for (const f of mod.APP_FEATURES) {
84
80
  set.add(f.name);
85
81
  }
82
+ // HAS_AUTH defaults to true (Studio/use-all-bundled convention). When set,
83
+ // include the implicit auth-mode features so the diff doesn't false-positive
84
+ // "auth-email-password is mounted but no registry-entry".
86
85
  if (mod.HAS_AUTH ?? true) {
87
- set.add("config");
88
- set.add("user");
89
- set.add("tenant");
90
- set.add("auth-email-password");
86
+ for (const name of implicitAuthModeFeatureNames()) set.add(name);
91
87
  }
92
88
  return set;
93
89
  }
@@ -156,4 +152,8 @@ async function main(): Promise<void> {
156
152
  }
157
153
  }
158
154
 
159
- await main();
155
+ // Only run when executed as a CLI, not when imported (e.g. from tests that
156
+ // exercise the exported pure helpers).
157
+ if (import.meta.main) {
158
+ await main();
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.24.0",
3
+ "version": "0.25.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>",
@@ -210,10 +210,17 @@ describe("runProdApp envSchema integration", () => {
210
210
  }
211
211
  });
212
212
 
213
- it("KUMIKO_DRY_RUN_ENV=boot still aggregates env-errors before exit", async () => {
213
+ it("KUMIKO_DRY_RUN_ENV=boot does not skip env-validation (no success handle on invalid env)", async () => {
214
+ // Env-error aggregation itself is mode-independent (covered above). The
215
+ // boot-specific guarantee here: the boot dry-run — which returns a handle
216
+ // and exits early on VALID env (see the test above) — must NOT report
217
+ // success on INVALID env. It aborts with the aggregated errors and returns
218
+ // no handle, so a regression that made boot-mode bail out before validation
219
+ // would surface as a falsely-returned handle.
214
220
  let captured: KumikoBootError | undefined;
221
+ let handle: unknown;
215
222
  try {
216
- await runProdApp({
223
+ handle = await runProdApp({
217
224
  features: [secretsFeature, authFeature],
218
225
  envSchema: composed,
219
226
  envSource: {
@@ -229,6 +236,7 @@ describe("runProdApp envSchema integration", () => {
229
236
  } catch (err) {
230
237
  expect(err).toBeInstanceOf(KumikoBootError);
231
238
  }
239
+ expect(handle).toBeUndefined();
232
240
  expect(captured).toBeDefined();
233
241
  expect(captured!.errors.length).toBeGreaterThanOrEqual(3);
234
242
  });
@@ -32,6 +32,51 @@ describe("frameworkCoreEnvSchema", () => {
32
32
  }
33
33
  });
34
34
 
35
+ it("rejects a non-postgres DATABASE_URL even when it is a valid WHATWG URL", () => {
36
+ try {
37
+ parseEnv(frameworkCoreEnvSchema, {
38
+ DATABASE_URL: "https://example.com/db",
39
+ REDIS_URL: "redis://localhost:6379",
40
+ });
41
+ throw new Error("should have thrown");
42
+ } catch (err) {
43
+ expect(err).toBeInstanceOf(KumikoBootError);
44
+ const db = (err as KumikoBootError).errors.find((e) => e.name === "DATABASE_URL");
45
+ expect(db?.kind).toBe("invalid");
46
+ }
47
+ });
48
+
49
+ it("accepts postgres:// and postgresql:// for DATABASE_URL", () => {
50
+ for (const url of ["postgres://localhost:5432/db", "postgresql://localhost:5432/db"]) {
51
+ const env = parseEnv(frameworkCoreEnvSchema, {
52
+ DATABASE_URL: url,
53
+ REDIS_URL: "redis://localhost:6379",
54
+ });
55
+ expect(env.DATABASE_URL).toBe(url);
56
+ }
57
+ });
58
+
59
+ it("rejects a non-redis REDIS_URL and accepts redis:// + rediss://", () => {
60
+ try {
61
+ parseEnv(frameworkCoreEnvSchema, {
62
+ DATABASE_URL: "postgres://localhost:5432/db",
63
+ REDIS_URL: "https://example.com/cache",
64
+ });
65
+ throw new Error("should have thrown");
66
+ } catch (err) {
67
+ expect(err).toBeInstanceOf(KumikoBootError);
68
+ const redis = (err as KumikoBootError).errors.find((e) => e.name === "REDIS_URL");
69
+ expect(redis?.kind).toBe("invalid");
70
+ }
71
+ for (const url of ["redis://localhost:6379", "rediss://localhost:6379"]) {
72
+ const env = parseEnv(frameworkCoreEnvSchema, {
73
+ DATABASE_URL: "postgres://localhost:5432/db",
74
+ REDIS_URL: url,
75
+ });
76
+ expect(env.REDIS_URL).toBe(url);
77
+ }
78
+ });
79
+
35
80
  it("rejects an invalid PORT (non-numeric)", () => {
36
81
  try {
37
82
  parseEnv(frameworkCoreEnvSchema, {
@@ -70,6 +115,7 @@ describe("frameworkCoreEnvSchema", () => {
70
115
 
71
116
  try {
72
117
  parseEnv(schema, {}, { sources });
118
+ throw new Error("should have thrown");
73
119
  } catch (err) {
74
120
  const boot = err as KumikoBootError;
75
121
  const formatted = boot.format();
@@ -0,0 +1,57 @@
1
+ // Unit-tests for the pure helpers behind the kumiko-schema-check bin.
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { composeFeatures } from "../compose-features";
8
+ import { implicitAuthModeFeatureNames, resolveGeneratePath } from "../schema-check-core";
9
+
10
+ describe("resolveGeneratePath", () => {
11
+ test("defaults to drizzle/generate.ts when neither candidate exists", () => {
12
+ const cwd = mkdtempSync(join(tmpdir(), "schema-check-"));
13
+ try {
14
+ expect(resolveGeneratePath(cwd)).toBe(join(cwd, "drizzle/generate.ts"));
15
+ } finally {
16
+ rmSync(cwd, { recursive: true, force: true });
17
+ }
18
+ });
19
+
20
+ test("falls back to schema/generate.ts when drizzle/ is absent but schema/ exists", () => {
21
+ const cwd = mkdtempSync(join(tmpdir(), "schema-check-"));
22
+ try {
23
+ mkdirSync(join(cwd, "schema"), { recursive: true });
24
+ writeFileSync(join(cwd, "schema/generate.ts"), "// stub", "utf-8");
25
+ expect(resolveGeneratePath(cwd)).toBe(join(cwd, "schema/generate.ts"));
26
+ } finally {
27
+ rmSync(cwd, { recursive: true, force: true });
28
+ }
29
+ });
30
+
31
+ test("prefers drizzle/generate.ts when both exist", () => {
32
+ const cwd = mkdtempSync(join(tmpdir(), "schema-check-"));
33
+ try {
34
+ mkdirSync(join(cwd, "drizzle"), { recursive: true });
35
+ mkdirSync(join(cwd, "schema"), { recursive: true });
36
+ writeFileSync(join(cwd, "drizzle/generate.ts"), "// stub", "utf-8");
37
+ writeFileSync(join(cwd, "schema/generate.ts"), "// stub", "utf-8");
38
+ expect(resolveGeneratePath(cwd)).toBe(join(cwd, "drizzle/generate.ts"));
39
+ } finally {
40
+ rmSync(cwd, { recursive: true, force: true });
41
+ }
42
+ });
43
+ });
44
+
45
+ describe("implicitAuthModeFeatureNames", () => {
46
+ test("matches composeFeatures' auth-mode prepend exactly (no hardcoded drift)", () => {
47
+ const fromCompose = composeFeatures([], { includeBundled: true }).map((f) => f.name);
48
+ expect(implicitAuthModeFeatureNames()).toEqual(fromCompose);
49
+ // Sanity: the current bundled-foundation set.
50
+ expect([...implicitAuthModeFeatureNames()].sort()).toEqual([
51
+ "auth-email-password",
52
+ "config",
53
+ "tenant",
54
+ "user",
55
+ ]);
56
+ });
57
+ });
@@ -0,0 +1,118 @@
1
+ // Regression: the boot-path must read the injected `envSource`, not the real
2
+ // process.env. Boot-mode (KUMIKO_DRY_RUN_ENV=boot) validates wiring + builds
3
+ // the registry, then tears down the lazy DB/Redis clients before any socket
4
+ // opens — so this runs without a real Postgres/Redis (same as the CI boot
5
+ // smoke). Before the fix, requireEnv/readEnv read process.env directly, so the
6
+ // required-var test would throw "required env var DATABASE_URL is missing" and
7
+ // the PORT test would bind the default instead of the injected port.
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import {
11
+ createBooleanField,
12
+ createEntity,
13
+ createTextField,
14
+ defineFeature,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { z } from "zod";
17
+ import { runProdApp } from "../run-prod-app";
18
+
19
+ const probeEntity = createEntity({
20
+ fields: {
21
+ name: createTextField({ required: true }),
22
+ active: createBooleanField({ default: true }),
23
+ },
24
+ table: "env_source_probe",
25
+ });
26
+
27
+ const probeFeature = defineFeature("env-source-probe", (r) => {
28
+ r.entity("widget", probeEntity);
29
+ r.queryHandler({
30
+ name: "ping",
31
+ schema: z.object({}),
32
+ access: { roles: ["anonymous"] },
33
+ handler: async () => ({ pong: true }),
34
+ });
35
+ });
36
+
37
+ // Cleared from process.env so the test fully controls config via envSource.
38
+ // DATABASE_URL/REDIS_URL/JWT_SECRET are required (their read throws pre-fix);
39
+ // PORT is non-throwing, cleared only so ambient PORT can't mask the second
40
+ // test's "PORT comes from envSource" assertion.
41
+ const CLEARED_VARS = ["DATABASE_URL", "REDIS_URL", "JWT_SECRET", "PORT"] as const;
42
+
43
+ const DUMMY_ENV = {
44
+ KUMIKO_DRY_RUN_ENV: "boot",
45
+ DATABASE_URL: "postgres://smoke:smoke@127.0.0.1:1/smoke",
46
+ REDIS_URL: "redis://127.0.0.1:1",
47
+ JWT_SECRET: "smokesmokesmokesmokesmokesmokesmokesmoke",
48
+ } as const;
49
+
50
+ describe("runProdApp boot-mode env-source", () => {
51
+ const saved: Record<string, string | undefined> = {};
52
+
53
+ beforeEach(() => {
54
+ for (const k of CLEARED_VARS) {
55
+ saved[k] = process.env[k];
56
+ delete process.env[k];
57
+ }
58
+ });
59
+
60
+ afterEach(() => {
61
+ for (const k of CLEARED_VARS) {
62
+ if (saved[k] === undefined) delete process.env[k];
63
+ else process.env[k] = saved[k];
64
+ }
65
+ });
66
+
67
+ test("boots from injected envSource even when process.env lacks the required vars", async () => {
68
+ const logs: string[] = [];
69
+ const originalLog = console.log;
70
+ console.log = (...args: unknown[]) => {
71
+ logs.push(args.map(String).join(" "));
72
+ };
73
+ let handle: Awaited<ReturnType<typeof runProdApp>>;
74
+ try {
75
+ handle = await runProdApp({
76
+ features: [probeFeature],
77
+ autoListen: false,
78
+ migrations: false,
79
+ // REDIS_URL points at an unreachable port — boot-mode must NOT
80
+ // construct the (eager) Redis client, so this never tries to connect.
81
+ envSource: { ...DUMMY_ENV },
82
+ });
83
+ } finally {
84
+ console.log = originalLog;
85
+ }
86
+
87
+ // Boot-mode with an injected envSource returns an inert dry-run handle.
88
+ expect(handle).toBeDefined();
89
+ expect(typeof handle.stop).toBe("function");
90
+ // The registry was built + validated before any connection was opened.
91
+ expect(logs.some((line) => line.includes("boot validation OK"))).toBe(true);
92
+ await handle.stop();
93
+ });
94
+
95
+ test("resolves PORT from envSource, not process.env", async () => {
96
+ const logs: string[] = [];
97
+ const originalLog = console.log;
98
+ console.log = (...args: unknown[]) => {
99
+ logs.push(args.map(String).join(" "));
100
+ };
101
+ try {
102
+ const handle = await runProdApp({
103
+ features: [probeFeature],
104
+ autoListen: false,
105
+ migrations: false,
106
+ envSource: { ...DUMMY_ENV, PORT: "8123" },
107
+ });
108
+ await handle.stop();
109
+ } finally {
110
+ console.log = originalLog;
111
+ }
112
+
113
+ // The boot logs "booting Kumiko stack on port <port>" — pre-fix this read
114
+ // process.env["PORT"] (deleted here) and would log the 3000 default.
115
+ expect(logs.some((line) => line.includes("port 8123"))).toBe(true);
116
+ expect(logs.some((line) => line.includes("port 3000"))).toBe(false);
117
+ });
118
+ });
@@ -1,7 +1,7 @@
1
1
  // scaffoldAppFeature unit-tests (DX-2).
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
- import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { scaffoldApp } from "../scaffold-app";
@@ -50,15 +50,13 @@ describe("scaffoldAppFeature", () => {
50
50
  expect(runConfig).toMatch(/\]\s*as const;/);
51
51
  });
52
52
 
53
- test("idempotent: re-mount of existing feature is a no-op", () => {
53
+ test("mounting a second feature keeps the first feature's import exactly once", () => {
54
+ // Cross-feature non-duplication: adding billing must not touch the
55
+ // product-catalog import/entry. Same-feature re-mount idempotency is the
56
+ // next test — the dir-exists guard blocks a direct second scaffold of the
57
+ // same name, so this one cannot reach the short-circuit branches.
54
58
  scaffoldAppFeature({ name: "product-catalog", appRoot });
55
59
  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
60
  scaffoldAppFeature({ name: "billing", appRoot });
63
61
  const secondRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
64
62
  expect(secondRunConfig).toContain("productCatalogFeature");
@@ -69,11 +67,88 @@ describe("scaffoldAppFeature", () => {
69
67
  expect(firstRunConfig.length).toBeLessThan(secondRunConfig.length);
70
68
  });
71
69
 
70
+ test("idempotent: re-scaffolding the same feature is a full no-op on run-config", () => {
71
+ scaffoldAppFeature({ name: "product-catalog", appRoot });
72
+ const runConfigPath = join(appRoot, "src/run-config.ts");
73
+ const afterFirst = readFileSync(runConfigPath, "utf-8");
74
+
75
+ // Drop only the feature dir so the dir-exists guard doesn't trip; the
76
+ // run-config import + APP_FEATURES entry both survive. The re-scaffold must
77
+ // hit the full short-circuit in mountInRunConfig (import present AND
78
+ // alreadyListed → changed=false → no save) and duplicate neither half —
79
+ // the branch the cross-feature test above never reaches.
80
+ rmSync(join(appRoot, "src/features/product-catalog"), { recursive: true, force: true });
81
+ const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
82
+ expect(result.autoMounted).toBe(true);
83
+
84
+ const afterSecond = readFileSync(runConfigPath, "utf-8");
85
+ expect(afterSecond).toBe(afterFirst); // byte-identical: no save, no duplication
86
+ expect((afterSecond.match(/productCatalogFeature/g) ?? []).length).toBe(2);
87
+ });
88
+
89
+ test("self-heals a half-mounted run-config: import present but APP_FEATURES entry removed", () => {
90
+ scaffoldAppFeature({ name: "product-catalog", appRoot });
91
+ const runConfigPath = join(appRoot, "src/run-config.ts");
92
+
93
+ // Simulate a half-applied state: the feature dir is gone and the
94
+ // APP_FEATURES entry was hand-removed, but the import line still lingers.
95
+ rmSync(join(appRoot, "src/features/product-catalog"), { recursive: true, force: true });
96
+ const stripped = readFileSync(runConfigPath, "utf-8")
97
+ // Drop the APP_FEATURES array element (appended last, so `, productCatalogFeature`
98
+ // right before the closing `]`). The import `{ productCatalogFeature }` is
99
+ // followed by ` }`, not `]`/`,`, so the lookahead leaves it intact.
100
+ .replace(/,\s*productCatalogFeature(?=\s*\])/, "");
101
+ writeFileSync(runConfigPath, stripped, "utf-8");
102
+ // Guard the simulation itself: the import must remain, the array entry must
103
+ // be gone — exactly one mention left. Without this, a regex that failed to
104
+ // strip would leave the entry in place and the test would false-pass.
105
+ expect(stripped).toContain(
106
+ 'import { productCatalogFeature } from "./features/product-catalog";',
107
+ );
108
+ expect((stripped.match(/productCatalogFeature/g) ?? []).length).toBe(1);
109
+
110
+ // Re-scaffold: dir is gone so it proceeds; mountInRunConfig sees the import
111
+ // already present and must still re-add the missing APP_FEATURES entry.
112
+ const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
113
+ expect(result.autoMounted).toBe(true);
114
+
115
+ const healed = readFileSync(runConfigPath, "utf-8");
116
+ // Import stays exactly once; the array entry is back.
117
+ const occurrences = healed.match(/productCatalogFeature/g) ?? [];
118
+ expect(occurrences.length).toBe(2); // 1 import + 1 array-entry
119
+ expect(healed).toMatch(/\]\s*as const;/);
120
+ });
121
+
72
122
  test("rejects non-kebab-case", () => {
73
123
  expect(() => scaffoldAppFeature({ name: "ProductCatalog", appRoot })).toThrow(/kebab-case/);
74
124
  expect(() => scaffoldAppFeature({ name: "product_catalog", appRoot })).toThrow(/kebab-case/);
75
125
  });
76
126
 
127
+ test("rejects trailing- and double-hyphen names (kebabToCamel would break)", () => {
128
+ // `product-` → kebabToCamel leaves a trailing hyphen → `product-Feature`,
129
+ // an invalid identifier. The segment-strict regex must reject these.
130
+ expect(() => scaffoldAppFeature({ name: "product-", appRoot })).toThrow(/kebab-case/);
131
+ expect(() => scaffoldAppFeature({ name: "foo--bar", appRoot })).toThrow(/kebab-case/);
132
+ });
133
+
134
+ test("rolls back the scaffolded dir when run-config has no APP_FEATURES (re-run not blocked)", () => {
135
+ // run-config exists but is the wrong shape → mountInRunConfig throws. The
136
+ // feature files were already written; without rollback a re-run would hit
137
+ // the "already exists" guard and the user would be stuck.
138
+ const runConfigPath = join(appRoot, "src/run-config.ts");
139
+ writeFileSync(runConfigPath, "export const NOT_APP_FEATURES = [];\n", "utf-8");
140
+
141
+ expect(() => scaffoldAppFeature({ name: "orders", appRoot })).toThrow(/APP_FEATURES/);
142
+ // Rolled back: the half-written feature dir is gone.
143
+ expect(existsSync(join(appRoot, "src/features/orders"))).toBe(false);
144
+
145
+ // Re-run with a valid run-config now succeeds instead of "already exists".
146
+ writeFileSync(runConfigPath, "export const APP_FEATURES = [] as const;\n", "utf-8");
147
+ const result = scaffoldAppFeature({ name: "orders", appRoot });
148
+ expect(result.autoMounted).toBe(true);
149
+ expect(existsSync(join(appRoot, "src/features/orders/feature.ts"))).toBe(true);
150
+ });
151
+
77
152
  test("refuses to overwrite existing feature dir", () => {
78
153
  scaffoldAppFeature({ name: "billing", appRoot });
79
154
  expect(() => scaffoldAppFeature({ name: "billing", appRoot })).toThrow(/already exists/);
@@ -63,6 +63,21 @@ describe("scaffoldApp", () => {
63
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
64
  });
65
65
 
66
+ test("bin/main.ts composes the auth-mode feature set into envSchema (JWT_SECRET boot-gate)", () => {
67
+ const dest = join(tmp, "my-shop");
68
+ scaffoldApp({ name: "my-shop", destination: dest });
69
+
70
+ const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
71
+ // envSchema must cover the same features runProdApp auto-mixes via
72
+ // auth-mode — otherwise auth-email-password's JWT_SECRET (min-32) is
73
+ // absent from the boot-gate and a too-short secret slips through.
74
+ expect(main).toContain("composeFeatures(APP_FEATURES, { includeBundled: true })");
75
+ expect(main).toContain(
76
+ "composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures })",
77
+ );
78
+ expect(main).toContain("composeFeatures");
79
+ });
80
+
66
81
  test("src/run-config.ts mounts secrets + sessions as foundation", () => {
67
82
  const dest = join(tmp, "my-shop");
68
83
  scaffoldApp({ name: "my-shop", destination: dest });
@@ -79,6 +94,25 @@ describe("scaffoldApp", () => {
79
94
  expect(() => scaffoldApp({ name: "0shop", destination: tmp })).toThrow(/kebab-case/);
80
95
  });
81
96
 
97
+ test("rejects trailing- and double-hyphen names (invalid package segment)", () => {
98
+ expect(() => scaffoldApp({ name: "my-shop-", destination: tmp })).toThrow(/kebab-case/);
99
+ expect(() => scaffoldApp({ name: "my--shop", destination: tmp })).toThrow(/kebab-case/);
100
+ });
101
+
102
+ test("resolves a relative destination against the supplied cwd, not process.cwd()", () => {
103
+ // The CLI passes ctx.cwd; the scaffold must land under it so the
104
+ // displayed path matches the actual write location.
105
+ const result = scaffoldApp({ name: "shop", destination: "apps/shop", cwd: tmp });
106
+ expect(result.destination).toBe(join(tmp, "apps/shop"));
107
+ expect(existsSync(join(tmp, "apps/shop", "package.json"))).toBe(true);
108
+ });
109
+
110
+ test("resolves the name-default destination against the supplied cwd", () => {
111
+ const result = scaffoldApp({ name: "shop", cwd: tmp });
112
+ expect(result.destination).toBe(join(tmp, "shop"));
113
+ expect(existsSync(join(tmp, "shop", "package.json"))).toBe(true);
114
+ });
115
+
82
116
  test("refuses to overwrite existing destination", () => {
83
117
  const dest = join(tmp, "existing");
84
118
  scaffoldApp({ name: "existing", destination: dest });
@@ -1,8 +1,8 @@
1
- import { afterEach, beforeEach, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test";
2
2
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { scaffoldDeploy } from "../scaffold-deploy";
5
+ import { render, scaffoldDeploy } from "../scaffold-deploy";
6
6
 
7
7
  describe("scaffoldDeploy", () => {
8
8
  let tmp: string;
@@ -41,6 +41,16 @@ describe("scaffoldDeploy", () => {
41
41
  expect(migrate).toContain("ghcr.io/acme/myapp:latest");
42
42
  // biome-ignore lint/suspicious/noTemplateCurlyInString: shell-substitution literal, not a JS template
43
43
  expect(migrate).toContain("postgresql://myapp:${DB_PASSWORD}@db:5432/myapp");
44
+ // The password-bearing URL is composed into the shell env and passed by
45
+ // NAME (`-e DATABASE_URL`, no `=`), so the expanded value never lands in
46
+ // `docker run`'s argv (would be visible in `ps auxe`).
47
+ expect(migrate).toContain("export DATABASE_URL=");
48
+ expect(migrate).toMatch(/-e DATABASE_URL\b(?!=)/);
49
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: shell-substitution literal, not a JS template
50
+ expect(migrate).not.toContain('-e DATABASE_URL="postgresql://myapp:${DB_PASSWORD}');
51
+ // grep's no-match exit must not abort the script before the friendly
52
+ // "No _stack network found" branch runs.
53
+ expect(migrate).toContain("| head -1 || true)");
44
54
  // Docker template syntax {{.Name}} must pass through verbatim — our
45
55
  // placeholder regex only matches lowercase-leading identifiers.
46
56
  expect(migrate).toContain('"{{.Name}}"');
@@ -148,6 +158,27 @@ describe("scaffoldDeploy", () => {
148
158
  );
149
159
  });
150
160
 
161
+ describe("render placeholder consumption", () => {
162
+ it("passes Docker/Go template syntax {{.X}} through verbatim", () => {
163
+ const out = render('docker network ls --format "{{.Name}}"', {}, {});
164
+ expect(out).toBe('docker network ls --format "{{.Name}}"');
165
+ });
166
+
167
+ it("substitutes known {{key}} placeholders", () => {
168
+ expect(render("hello {{who}}", { who: "world" }, {})).toBe("hello world");
169
+ });
170
+
171
+ it("throws on an orphan close-tag (no opener) instead of leaking it", () => {
172
+ expect(() => render("line\n{{/hasSeeds}}\nmore", {}, {})).toThrow(/unconsumed mustache/);
173
+ });
174
+
175
+ it("strips a matched block but does not leave its close-tag behind", () => {
176
+ const out = render("a\n{{#flag}}\nkept\n{{/flag}}\nb", {}, { flag: true });
177
+ expect(out).toContain("kept");
178
+ expect(out).not.toContain("{{/flag}}");
179
+ });
180
+ });
181
+
151
182
  describe("source-tree detection", () => {
152
183
  it("emits seeds-COPY block only when seeds/ exists", () => {
153
184
  // Without seeds/: block stripped
@@ -205,10 +236,17 @@ describe("scaffoldDeploy", () => {
205
236
  expect(df).not.toContain("ENV GITHUB_TOKEN");
206
237
  });
207
238
 
208
- it("malformed package.json doesn't crash detection (defaults to no private deps)", () => {
209
- writeFileSync(join(tmp, "package.json"), "{ this is not json");
210
- const result = scaffoldDeploy({ appName: "broken", destination: tmp });
211
- expect(result.detected.hasPrivateGhPackages).toBe(false);
239
+ it("malformed package.json warns + defaults to no private deps (mis-detection is visible)", () => {
240
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
241
+ try {
242
+ writeFileSync(join(tmp, "package.json"), "{ this is not json");
243
+ const result = scaffoldDeploy({ appName: "broken", destination: tmp });
244
+ expect(result.detected.hasPrivateGhPackages).toBe(false);
245
+ expect(warn).toHaveBeenCalledTimes(1);
246
+ expect(warn.mock.calls[0]?.[0]).toContain("is not valid JSON");
247
+ } finally {
248
+ warn.mockRestore();
249
+ }
212
250
  });
213
251
  });
214
252
  });
@@ -39,6 +39,28 @@ describe("renderInlineSchemasFile", () => {
39
39
  expect(out).toContain("// inventory:event:product-archived — from src/feature.ts:92");
40
40
  expect(out).not.toContain(appRoot);
41
41
  });
42
+
43
+ test("falls back to the bare filename when a feature file is outside the app root", () => {
44
+ // A bundled-feature file lives in node_modules, outside the app root —
45
+ // relative() would emit `../../..`. The comment falls back to basename.
46
+ const appRoot = join(tmpdir(), "kumiko-codegen-app");
47
+ const featurePath = join(tmpdir(), "node_modules", "pkg", "dist", "feature.ts");
48
+ const events: ScannedEvent[] = [
49
+ {
50
+ qualifiedName: "bundled:event:thing-happened",
51
+ schemaSource: {
52
+ kind: "inline",
53
+ schemaSource: "z.object({ id: z.string() })",
54
+ generatedConstName: "_kg_bundled__thingHappened",
55
+ },
56
+ featureFilePath: featurePath,
57
+ source: { file: featurePath, line: 7 },
58
+ },
59
+ ];
60
+ const out = renderInlineSchemasFile(events, appRoot);
61
+ expect(out).toContain("// bundled:event:thing-happened — from feature.ts:7");
62
+ expect(out).not.toContain("..");
63
+ });
42
64
  });
43
65
 
44
66
  describe("renderDefineFile", () => {
@@ -22,7 +22,7 @@
22
22
  // actual change, so mtime doesn't tick and the TS language server
23
23
  // doesn't reload every 100ms.
24
24
 
25
- import { relative } from "node:path";
25
+ import { basename, relative } from "node:path";
26
26
  import type { ScannedEvent } from "./scan-events";
27
27
  import { rewriteImportPath } from "./scan-events";
28
28
 
@@ -143,7 +143,11 @@ export function renderInlineSchemasFile(
143
143
  });
144
144
  for (const ev of sorted) {
145
145
  if (ev.schemaSource.kind !== "inline") continue;
146
- const sourcePath = relative(appRootAbs, ev.featureFilePath).split("\\").join("/");
146
+ const rel = relative(appRootAbs, ev.featureFilePath).split("\\").join("/");
147
+ // Feature files outside the app root (e.g. a bundled dependency) yield a
148
+ // `../../..`-walk that clutters the source comment; fall back to the bare
149
+ // filename. Comment-only — the generated schema is unaffected.
150
+ const sourcePath = rel.startsWith("..") ? basename(ev.featureFilePath) : rel;
147
151
  lines.push(
148
152
  `// ${ev.qualifiedName} — from ${sourcePath}:${ev.source.line}`,
149
153
  `export const ${ev.schemaSource.generatedConstName} = ${ev.schemaSource.schemaSource};`,
package/src/env-schema.ts CHANGED
@@ -27,12 +27,16 @@ export const frameworkCoreEnvSchema = z.object({
27
27
  .describe("HTTP listen port. runProdApp defaults to 3000 when unset."),
28
28
 
29
29
  DATABASE_URL: z
30
- .url("DATABASE_URL must be a valid postgres:// URL")
30
+ // `protocol` is matched against the URL scheme without the trailing
31
+ // colon — `postgres://`/`postgresql://` pass, `https://`/`ftp://` are
32
+ // rejected so the error message's promise actually holds at boot.
33
+ .url({ protocol: /^postgres(ql)?$/, error: "DATABASE_URL must be a valid postgres:// URL" })
31
34
  .describe("Primary Postgres connection string (write + read).")
32
35
  .meta({ kumiko: { pulumi: { secret: true } } }),
33
36
 
34
37
  REDIS_URL: z
35
- .url("REDIS_URL must be a valid redis:// URL")
38
+ // `redis://` + the TLS variant `rediss://` pass; other schemes reject.
39
+ .url({ protocol: /^rediss?$/, error: "REDIS_URL must be a valid redis:// URL" })
36
40
  .describe("Redis connection string for SSE-broker + job-queues.")
37
41
  .meta({ kumiko: { pulumi: { secret: true } } }),
38
42
 
@@ -47,7 +51,7 @@ export const frameworkCoreEnvSchema = z.object({
47
51
  ),
48
52
 
49
53
  // `z.string().optional()` (not `z.literal("1")`) — the run-prod-app
50
- // call-site (`process.env["KUMIKO_SKIP_ES_OPS"] !== "1"`) ignores any
54
+ // call-site (`envSource["KUMIKO_SKIP_ES_OPS"] !== "1"`) ignores any
51
55
  // value other than literal "1". A stricter schema would reject e.g.
52
56
  // "true" / "yes" that the runtime silently ignores, surfacing
53
57
  // boot-errors for inputs the framework doesn't actually care about.
@@ -110,8 +110,11 @@ export function buildBunServeOptions(
110
110
 
111
111
  // Strict env-var read. Throws with a clear hint when missing — better
112
112
  // than discovering a Postgres-connection-refused 30s into the boot.
113
- function requireEnv(name: string): string {
114
- const value = process.env[name];
113
+ // `src` defaults to process.env but is threaded from the caller's envSource
114
+ // so the boot-path reads the SAME env-quelle that was validated above —
115
+ // injected dummies in test-mode must not silently fall back to process.env.
116
+ function requireEnv(name: string, src: Record<string, string | undefined> = process.env): string {
117
+ const value = src[name];
115
118
  if (value === undefined || value === "") {
116
119
  throw new Error(
117
120
  `runProdApp: required env var "${name}" is missing or empty. ` +
@@ -123,8 +126,11 @@ function requireEnv(name: string): string {
123
126
 
124
127
  // Optional env helper — returns undefined for missing, string for set.
125
128
  // Used for KUMIKO_INSTANCE_ID, JWT_ISSUER and other "nice to have" knobs.
126
- function readEnv(name: string): string | undefined {
127
- const value = process.env[name];
129
+ function readEnv(
130
+ name: string,
131
+ src: Record<string, string | undefined> = process.env,
132
+ ): string | undefined {
133
+ const value = src[name];
128
134
  return value === undefined || value === "" ? undefined : value;
129
135
  }
130
136
 
@@ -535,26 +541,21 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
535
541
  // 2. Env-vars: fail-fast. Better a 0s boot crash with a clear error
536
542
  // than a 30s timeout chasing a Postgres connection that was never
537
543
  // configured.
538
- const databaseUrl = requireEnv("DATABASE_URL");
539
- const redisUrl = requireEnv("REDIS_URL");
540
- const jwtSecret = requireEnv("JWT_SECRET");
541
- const jwtIssuer = readEnv("JWT_ISSUER");
542
- const instanceId = readEnv("KUMIKO_INSTANCE_ID");
543
- const port = options.port ?? Number.parseInt(process.env["PORT"] ?? "3000", 10);
544
+ const databaseUrl = requireEnv("DATABASE_URL", envSource);
545
+ const redisUrl = requireEnv("REDIS_URL", envSource);
546
+ const jwtSecret = requireEnv("JWT_SECRET", envSource);
547
+ const jwtIssuer = readEnv("JWT_ISSUER", envSource);
548
+ const instanceId = readEnv("KUMIKO_INSTANCE_ID", envSource);
549
+ const port = options.port ?? Number.parseInt(envSource["PORT"] ?? "3000", 10);
544
550
 
545
551
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
546
552
  console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
547
553
 
548
- // 3. Connections — Postgres + Redis. The Redis client is shared by
549
- // idempotency, event-dedup, entity-cache, rate-limit; failing to
550
- // construct here surfaces the misconfig immediately.
551
- const { db, close: closeDb } = createDbConnection(databaseUrl);
552
- const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
553
-
554
- // 4. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
554
+ // 3. Feature registry. Auth-mode auto-mixes config/user/tenant/auth-email-
555
555
  // password via composeFeatures — same source-of-truth as runDevApp
556
556
  // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
557
- // sehen exakt dieselbe Liste.
557
+ // sehen exakt dieselbe Liste. Built BEFORE any connection so boot-mode
558
+ // can validate wiring and exit without opening a Postgres/Redis socket.
558
559
  const composeAuthOptions = buildComposeAuthOptions(options.auth);
559
560
  const features = composeFeatures(options.features, {
560
561
  includeBundled: !!options.auth,
@@ -564,22 +565,27 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
564
565
  validateBoot(features);
565
566
  const registry = createRegistry(features);
566
567
 
567
- // C1 boot-mode exit: validators ran, registry built, no DB/Redis
568
- // operations executed yet (postgres.js + ioredis are lazy). Tear down
569
- // the lazy clients so Bun doesn't keep them open, then exit / return.
568
+ // C1 boot-mode exit: validators ran + registry built; no DB/Redis client
569
+ // is constructed at all in this branch (the eager `new Redis(...)` below
570
+ // would otherwise open a TCP connect just to immediately disconnect it).
570
571
  if (runMode === "boot") {
571
572
  // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
572
573
  console.log(
573
574
  `[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
574
575
  );
575
- await closeDb();
576
- redis.disconnect();
577
576
  if (options.envSource === undefined) {
578
577
  process.exit(0);
579
578
  }
580
579
  return makeDryRunHandle();
581
580
  }
582
581
 
582
+ // 4. Connections — Postgres + Redis. The Redis client is shared by
583
+ // idempotency, event-dedup, entity-cache, rate-limit; failing to
584
+ // construct here surfaces the misconfig immediately. `new Redis(...)`
585
+ // connects eagerly, so it must stay AFTER the boot-mode exit above.
586
+ const { db, close: closeDb } = createDbConnection(databaseUrl);
587
+ const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
588
+
583
589
  // Sprint-8a Tier-Composition auto-wire: scan features for a
584
590
  // tenantTierResolver-extension. If found AND user didn't supply own
585
591
  // effectiveFeatures, build the resolver here (db + registry are
@@ -774,7 +780,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
774
780
  // if-missing"-Schicht; seed-migrations sind die "diff-and-update"-
775
781
  // Schicht für Drift den existing Seeds nicht erfassen können (z.B.
776
782
  // Membership-Roles-Change nach initialer Seed-Erstellung).
777
- if (options.seedsDir !== undefined && process.env["KUMIKO_SKIP_ES_OPS"] !== "1") {
783
+ if (options.seedsDir !== undefined && envSource["KUMIKO_SKIP_ES_OPS"] !== "1") {
778
784
  await createEsOperationsTable(db);
779
785
  const seedDispatcher = createDispatcher(registry, {
780
786
  db,
@@ -11,7 +11,7 @@
11
11
  // for the run-config side. Drizzle FEATURE_IMPORT_REGISTRY is NOT
12
12
  // touched here — DX-4 auto-discovery resolves that.
13
13
 
14
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { join, resolve } from "node:path";
16
16
  import { Project, SyntaxKind } from "ts-morph";
17
17
 
@@ -31,7 +31,10 @@ export type ScaffoldAppFeatureResult = {
31
31
  readonly autoMounted: boolean;
32
32
  };
33
33
 
34
- const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
34
+ // Segment-strict: rejects trailing/double hyphen (`product-`, `foo--bar`),
35
+ // which kebabToCamel would otherwise turn into an invalid identifier
36
+ // (`productFeature` vs the broken `product-Feature`).
37
+ const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
35
38
 
36
39
  export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): ScaffoldAppFeatureResult {
37
40
  if (!KEBAB_RE.test(options.name)) {
@@ -56,9 +59,19 @@ export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): Scaffold
56
59
  files.push(`src/features/${options.name}/index.ts`);
57
60
 
58
61
  const runConfigPath = join(appRoot, "src", "run-config.ts");
59
- const autoMounted = existsSync(runConfigPath)
60
- ? mountInRunConfig(runConfigPath, options.name)
61
- : false;
62
+ let autoMounted = false;
63
+ if (existsSync(runConfigPath)) {
64
+ try {
65
+ autoMounted = mountInRunConfig(runConfigPath, options.name);
66
+ } catch (err) {
67
+ // Roll back the freshly-written feature dir so a re-run isn't blocked
68
+ // by this function's own "already exists" guard. Without this, a
69
+ // shape-mismatch in run-config leaves feature.ts + index.ts on disk and
70
+ // the user is stuck having to hand-delete before retrying.
71
+ rmSync(featureDir, { recursive: true, force: true });
72
+ throw err;
73
+ }
74
+ }
62
75
 
63
76
  return {
64
77
  featureName: options.name,
@@ -98,8 +111,8 @@ function kebabToCamel(name: string): string {
98
111
  }
99
112
 
100
113
  // 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).
114
+ // Returns true on success, throws on shape-mismatch — the caller rolls back
115
+ // the scaffolded feature dir and re-throws so a re-run isn't blocked.
103
116
  function mountInRunConfig(runConfigPath: string, name: string): boolean {
104
117
  const camel = kebabToCamel(name);
105
118
  const project = new Project({
@@ -108,20 +121,25 @@ function mountInRunConfig(runConfigPath: string, name: string): boolean {
108
121
  });
109
122
  const sf = project.addSourceFileAtPath(runConfigPath);
110
123
 
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 =
118
- imports.length > 0 ? (imports[imports.length - 1]?.getChildIndex() ?? 0) + 1 : 0;
119
- sf.insertImportDeclaration(insertIndex, {
120
- moduleSpecifier: `./features/${name}`,
121
- namedImports: [`${camel}Feature`],
122
- });
124
+ // Import and APP_FEATURES entry are checked independently so a half-applied
125
+ // state self-heals: if the import exists but the entry was hand-removed (or
126
+ // vice versa), re-running adds only the missing half instead of short-
127
+ // circuiting on the import alone and leaving the feature unmounted.
128
+ let changed = false;
129
+
130
+ // 1. Prepend import after the last existing import — only if absent.
131
+ if (!sf.getImportDeclaration(`./features/${name}`)) {
132
+ const imports = sf.getImportDeclarations();
133
+ const insertIndex =
134
+ imports.length > 0 ? (imports[imports.length - 1]?.getChildIndex() ?? 0) + 1 : 0;
135
+ sf.insertImportDeclaration(insertIndex, {
136
+ moduleSpecifier: `./features/${name}`,
137
+ namedImports: [`${camel}Feature`],
138
+ });
139
+ changed = true;
140
+ }
123
141
 
124
- // 2. Find `export const APP_FEATURES = [...]` and append the new entry.
142
+ // 2. Find `export const APP_FEATURES = [...]` and append the entry — only if absent.
125
143
  const appFeaturesDecl = sf.getVariableDeclaration("APP_FEATURES");
126
144
  if (!appFeaturesDecl) {
127
145
  throw new Error(
@@ -143,9 +161,13 @@ function mountInRunConfig(runConfigPath: string, name: string): boolean {
143
161
  if (!arr) {
144
162
  throw new Error(`mountInRunConfig: APP_FEATURES is not an array literal — cannot auto-mount.`);
145
163
  }
146
- arr.addElement(`${camel}Feature`);
164
+ const alreadyListed = arr.getElements().some((el) => el.getText() === `${camel}Feature`);
165
+ if (!alreadyListed) {
166
+ arr.addElement(`${camel}Feature`);
167
+ changed = true;
168
+ }
147
169
 
148
- sf.saveSync();
170
+ if (changed) sf.saveSync();
149
171
  return true;
150
172
  }
151
173
 
@@ -20,6 +20,11 @@ export type ScaffoldAppOptions = {
20
20
  readonly name: string;
21
21
  /** Absolute or cwd-relative target dir. Default: <cwd>/<name>. */
22
22
  readonly destination?: string;
23
+ /** Base dir a relative `destination` (or the name-default) resolves
24
+ * against. Defaults to process.cwd(). Callers with their own cwd-notion
25
+ * (the CLI's ctx.cwd) MUST pass it so the scaffold lands where the
26
+ * command's output claims it does. */
27
+ readonly cwd?: string;
23
28
  /** npm-version-pin for @cosmicdrift/* deps. Default "*" for latest. */
24
29
  readonly frameworkVersion?: string;
25
30
  };
@@ -30,13 +35,15 @@ export type ScaffoldAppResult = {
30
35
  readonly appName: string;
31
36
  };
32
37
 
33
- const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
38
+ // Segment-strict: rejects trailing/double hyphen so the name is a valid
39
+ // package-name + folder (`my-shop`, not `my-` or `my--shop`).
40
+ const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
34
41
 
35
42
  export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
36
43
  if (!KEBAB_RE.test(options.name)) {
37
44
  throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
38
45
  }
39
- const cwd = process.cwd();
46
+ const cwd = options.cwd ?? process.cwd();
40
47
  const destination = resolve(cwd, options.destination ?? options.name);
41
48
  if (existsSync(destination)) {
42
49
  throw new Error(`scaffoldApp: ${destination} already exists — refusing to overwrite`);
@@ -178,7 +185,7 @@ function renderMain(appName: string): string {
178
185
 
179
186
  sf.addImportDeclaration({
180
187
  moduleSpecifier: "@cosmicdrift/kumiko-dev-server",
181
- namedImports: ["frameworkCoreEnvSchema", "runProdApp"],
188
+ namedImports: ["composeFeatures", "frameworkCoreEnvSchema", "runProdApp"],
182
189
  });
183
190
  sf.addImportDeclaration({
184
191
  moduleSpecifier: "@cosmicdrift/kumiko-framework/engine",
@@ -204,12 +211,27 @@ function renderMain(appName: string): string {
204
211
  ],
205
212
  });
206
213
 
214
+ // The envSchema must cover the SAME features runProdApp mounts at boot.
215
+ // `auth: { admin: … }` below makes runProdApp auto-mix config/user/tenant/
216
+ // auth-email-password via composeFeatures(includeBundled:true); compose the
217
+ // identical set here so the auth feature's JWT_SECRET (min-32) declaration
218
+ // is part of the boot-gate — otherwise a too-short JWT_SECRET slips through.
219
+ sf.addVariableStatement({
220
+ declarationKind: VariableDeclarationKind.Const,
221
+ declarations: [
222
+ {
223
+ name: "bootFeatures",
224
+ initializer: "composeFeatures(APP_FEATURES, { includeBundled: true })",
225
+ },
226
+ ],
227
+ });
228
+
207
229
  sf.addVariableStatement({
208
230
  declarationKind: VariableDeclarationKind.Const,
209
231
  declarations: [
210
232
  {
211
233
  name: "envSchema",
212
- initializer: "composeEnvSchema({ core: frameworkCoreEnvSchema, features: APP_FEATURES })",
234
+ initializer: "composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures })",
213
235
  },
214
236
  ],
215
237
  });
@@ -249,7 +271,7 @@ function renderMain(appName: string): string {
249
271
  "// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after",
250
272
  "// composeFeatures + validateBoot + createRegistry without DB/Redis-connect",
251
273
  "// (siehe @cosmicdrift/kumiko-dev-server runProdApp). Echter Dev-Boot",
252
- "// passiert via `bun kumiko dev` (in-repo dev-tool) mit Docker-stack — DX-1.0 deckt nur",
274
+ "// passiert via `bunx kumiko dev` (in-repo dev-tool) mit Docker-stack — DX-1.0 deckt nur",
253
275
  "// den boot-mode-Pfad ab; `kumiko dev` kommt in einer späteren DX-Phase.",
254
276
  "",
255
277
  "",
@@ -64,7 +64,9 @@ const TEMPLATE_FILES = [
64
64
  { template: "migrate-step.sh.template", output: "migrate-step.sh" },
65
65
  ] as const;
66
66
 
67
- const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
67
+ // Segment-strict: rejects trailing/double hyphen so the appName is a valid
68
+ // image-tag + folder segment (`kumiko-studio`, not `kumiko-` / `kumiko--x`).
69
+ const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
68
70
 
69
71
  export function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployResult {
70
72
  if (!KEBAB_RE.test(options.appName)) {
@@ -143,14 +145,26 @@ function detectOptionalSurfaces(sourceDir: string): ScaffoldDeployDetected {
143
145
  hasPrivateGhPackages = Object.keys(allDeps).some((d) =>
144
146
  d.startsWith("@cosmicdriftgamestudio/"),
145
147
  );
146
- } catch {
147
- // malformed package.json — assume no private packages, app-author can override via Dockerfile
148
+ } catch (err) {
149
+ // malformed package.json — assume no private packages, app-author can
150
+ // override via Dockerfile. Warn so a silent mis-detection (later YN0041
151
+ // on yarn install) is traceable to the scaffold step.
152
+ // biome-ignore lint/suspicious/noConsole: scaffold visibility for skipped private-package detection
153
+ console.warn(
154
+ `scaffoldDeploy: package.json at ${pkgJsonPath} is not valid JSON — private-GH-packages detection skipped (${err instanceof Error ? err.message : String(err)})`,
155
+ );
148
156
  }
149
157
  }
150
158
  return { hasSeeds, hasPrivateGhPackages };
151
159
  }
152
160
 
153
- function render(
161
+ // Unconsumed-mustache guard. After step 1+2 the only legitimate `{{`
162
+ // survivors are Docker/Go template tags (`{{.Name}}`, leading `.`). An
163
+ // orphan close-tag (`{{/foo}}` without an opener) matches neither step and
164
+ // would otherwise leak into the rendered file as a silent failure.
165
+ const ORPHAN_MUSTACHE_RE = /\{\{(?!\.)[^}]*\}\}/;
166
+
167
+ export function render(
154
168
  source: string,
155
169
  subs: Readonly<Record<string, string>>,
156
170
  flags: Readonly<Record<string, boolean>>,
@@ -182,5 +196,13 @@ function render(
182
196
  return value;
183
197
  });
184
198
 
199
+ const orphan = ORPHAN_MUSTACHE_RE.exec(result);
200
+ if (orphan) {
201
+ throw new Error(
202
+ `scaffoldDeploy.render: unconsumed mustache tag "${orphan[0]}" — ` +
203
+ `likely an orphan close-tag (e.g. a stray "{{/flag}}") in the template.`,
204
+ );
205
+ }
206
+
185
207
  return result;
186
208
  }
@@ -0,0 +1,26 @@
1
+ // Pure helpers for the `kumiko-schema-check` bin. Kept in src/ (vs. the bin)
2
+ // so they are part of the tsc project + unit-testable without importing the
3
+ // auto-running CLI.
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { composeFeatures } from "./compose-features";
8
+
9
+ // The generate-script lives under different roots across apps: publicstatus
10
+ // uses `drizzle/generate.ts`, the framework's own sample-apps use
11
+ // `schema/generate.ts`. Pick whichever exists so a no-arg `bunx
12
+ // kumiko-schema-check` works in both layouts; default to `drizzle/` for the
13
+ // error message when neither is present.
14
+ export function resolveGeneratePath(cwd: string): string {
15
+ const drizzlePath = resolve(cwd, "drizzle/generate.ts");
16
+ const schemaPath = resolve(cwd, "schema/generate.ts");
17
+ if (!existsSync(drizzlePath) && existsSync(schemaPath)) return schemaPath;
18
+ return drizzlePath;
19
+ }
20
+
21
+ // The features composeFeatures auto-prepends in auth-mode (config + user +
22
+ // tenant + auth-email-password). Derived from composeFeatures itself so this
23
+ // can't drift from the real prepend-list in compose-features.ts.
24
+ export function implicitAuthModeFeatureNames(): readonly string[] {
25
+ return composeFeatures([], { includeBundled: true }).map((f) => f.name);
26
+ }
@@ -16,7 +16,10 @@
16
16
 
17
17
  set -euo pipefail
18
18
 
19
- STACK_NETWORK=$(docker network ls --format "{{.Name}}" | grep -E "_stack$" | head -1)
19
+ # `|| true` keeps `set -e`/`pipefail` from aborting the script when `grep`
20
+ # finds no match (exit 1) — the empty-check below owns that case and prints
21
+ # the actionable hint instead of a bare pipefail abort.
22
+ STACK_NETWORK=$(docker network ls --format "{{.Name}}" | grep -E "_stack$" | head -1 || true)
20
23
  if [ -z "$STACK_NETWORK" ]; then
21
24
  echo "No _stack network found — compose project not running?" >&2
22
25
  exit 1
@@ -32,8 +35,14 @@ set +a
32
35
  # DATABASE_URL assumes: db user = appName, db name = appName, host "db"
33
36
  # (compose-service-name), port 5432. Adjust to your stack if different.
34
37
  # Image tag pinned to :latest — swap to :${BUILD_SHA} for atomic deploys.
38
+ #
39
+ # Compose DATABASE_URL into the shell env and pass it to the container by
40
+ # NAME (`-e DATABASE_URL`, no value) so the expanded password never appears
41
+ # in `docker run`'s argv — otherwise it would be visible in `ps auxe` for
42
+ # the duration of the migrate run.
43
+ export DATABASE_URL="postgresql://{{appName}}:${DB_PASSWORD}@db:5432/{{appName}}"
35
44
  docker run --rm \
36
45
  --network "$STACK_NETWORK" \
37
- -e DATABASE_URL="postgresql://{{appName}}:${DB_PASSWORD}@db:5432/{{appName}}" \
46
+ -e DATABASE_URL \
38
47
  ghcr.io/{{githubOrg}}/{{appName}}:latest \
39
48
  bun /app/kumiko.js schema apply