@cosmicdrift/kumiko-dev-server 0.24.1 → 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.1",
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
+ });
@@ -65,16 +65,30 @@ describe("runProdApp boot-mode env-source", () => {
65
65
  });
66
66
 
67
67
  test("boots from injected envSource even when process.env lacks the required vars", async () => {
68
- const handle = await runProdApp({
69
- features: [probeFeature],
70
- autoListen: false,
71
- migrations: false,
72
- envSource: { ...DUMMY_ENV },
73
- });
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
+ }
74
86
 
75
87
  // Boot-mode with an injected envSource returns an inert dry-run handle.
76
88
  expect(handle).toBeDefined();
77
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);
78
92
  await handle.stop();
79
93
  });
80
94
 
@@ -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 });
@@ -2,7 +2,7 @@ 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
@@ -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
 
@@ -551,16 +551,11 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
551
551
  // biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
552
552
  console.log(`[runProdApp] booting Kumiko stack on port ${port}…`);
553
553
 
554
- // 3. Connections — Postgres + Redis. The Redis client is shared by
555
- // idempotency, event-dedup, entity-cache, rate-limit; failing to
556
- // construct here surfaces the misconfig immediately.
557
- const { db, close: closeDb } = createDbConnection(databaseUrl);
558
- const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
559
-
560
- // 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-
561
555
  // password via composeFeatures — same source-of-truth as runDevApp
562
556
  // AND the per-app drizzle-Schema-Generator, so Migration und Runtime
563
- // 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.
564
559
  const composeAuthOptions = buildComposeAuthOptions(options.auth);
565
560
  const features = composeFeatures(options.features, {
566
561
  includeBundled: !!options.auth,
@@ -570,22 +565,27 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
570
565
  validateBoot(features);
571
566
  const registry = createRegistry(features);
572
567
 
573
- // C1 boot-mode exit: validators ran, registry built, no DB/Redis
574
- // operations executed yet (postgres.js + ioredis are lazy). Tear down
575
- // 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).
576
571
  if (runMode === "boot") {
577
572
  // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
578
573
  console.log(
579
574
  `[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
580
575
  );
581
- await closeDb();
582
- redis.disconnect();
583
576
  if (options.envSource === undefined) {
584
577
  process.exit(0);
585
578
  }
586
579
  return makeDryRunHandle();
587
580
  }
588
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
+
589
589
  // Sprint-8a Tier-Composition auto-wire: scan features for a
590
590
  // tenantTierResolver-extension. If found AND user didn't supply own
591
591
  // effectiveFeatures, build the resolver here (db + registry are
@@ -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
  });
@@ -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)) {
@@ -156,7 +158,13 @@ function detectOptionalSurfaces(sourceDir: string): ScaffoldDeployDetected {
156
158
  return { hasSeeds, hasPrivateGhPackages };
157
159
  }
158
160
 
159
- 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(
160
168
  source: string,
161
169
  subs: Readonly<Record<string, string>>,
162
170
  flags: Readonly<Record<string, boolean>>,
@@ -188,5 +196,13 @@ function render(
188
196
  return value;
189
197
  });
190
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
+
191
207
  return result;
192
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