@cosmicdrift/kumiko-dev-server 0.37.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/env-schema.test.ts +40 -47
- package/src/__tests__/kumiko-schema-check.test.ts +4 -5
- package/src/create-kumiko-server.ts +5 -2
- package/src/kebab.ts +7 -0
- package/src/run-prod-app.ts +2 -1
- package/src/scaffold-app-feature.ts +4 -6
- package/src/scaffold-app.ts +2 -5
- package/src/scaffold-deploy.ts +2 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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>",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
50
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
49
|
+
"@cosmicdrift/kumiko-bundled-features": "0.37.0",
|
|
50
|
+
"@cosmicdrift/kumiko-framework": "0.37.0",
|
|
51
51
|
"ts-morph": "^28.0.0"
|
|
52
52
|
},
|
|
53
53
|
"publishConfig": {
|
|
@@ -3,6 +3,20 @@ import { composeEnvSchema, KumikoBootError, parseEnv } from "@cosmicdrift/kumiko
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { type FrameworkCoreEnv, frameworkCoreEnvSchema } from "../env-schema";
|
|
5
5
|
|
|
6
|
+
// Statt Sentinel-throw IM try (ein nicht-werfender parseEnv ließe den
|
|
7
|
+
// Sentinel in den catch fallen und produziert eine irreführende
|
|
8
|
+
// instanceOf-Failure): laufen lassen, danach unconditional asserten.
|
|
9
|
+
function expectBootError(run: () => void): KumikoBootError {
|
|
10
|
+
let thrown: unknown;
|
|
11
|
+
try {
|
|
12
|
+
run();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
thrown = err;
|
|
15
|
+
}
|
|
16
|
+
expect(thrown).toBeInstanceOf(KumikoBootError);
|
|
17
|
+
return thrown as KumikoBootError;
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
describe("frameworkCoreEnvSchema", () => {
|
|
7
21
|
it("accepts a valid minimal env", () => {
|
|
8
22
|
const env = parseEnv(frameworkCoreEnvSchema, {
|
|
@@ -18,32 +32,23 @@ describe("frameworkCoreEnvSchema", () => {
|
|
|
18
32
|
});
|
|
19
33
|
|
|
20
34
|
it("aggregates missing required vars (DATABASE_URL + REDIS_URL)", () => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const names = boot.errors.map((e) => e.name).sort();
|
|
28
|
-
expect(names).toContain("DATABASE_URL");
|
|
29
|
-
expect(names).toContain("REDIS_URL");
|
|
30
|
-
// PORT has a default → not in the error set
|
|
31
|
-
expect(names).not.toContain("PORT");
|
|
32
|
-
}
|
|
35
|
+
const boot = expectBootError(() => parseEnv(frameworkCoreEnvSchema, {}));
|
|
36
|
+
const names = boot.errors.map((e) => e.name).sort();
|
|
37
|
+
expect(names).toContain("DATABASE_URL");
|
|
38
|
+
expect(names).toContain("REDIS_URL");
|
|
39
|
+
// PORT has a default → not in the error set
|
|
40
|
+
expect(names).not.toContain("PORT");
|
|
33
41
|
});
|
|
34
42
|
|
|
35
43
|
it("rejects a non-postgres DATABASE_URL even when it is a valid WHATWG URL", () => {
|
|
36
|
-
|
|
44
|
+
const boot = expectBootError(() =>
|
|
37
45
|
parseEnv(frameworkCoreEnvSchema, {
|
|
38
46
|
DATABASE_URL: "https://example.com/db",
|
|
39
47
|
REDIS_URL: "redis://localhost:6379",
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const db = (err as KumikoBootError).errors.find((e) => e.name === "DATABASE_URL");
|
|
45
|
-
expect(db?.kind).toBe("invalid");
|
|
46
|
-
}
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
const db = boot.errors.find((e) => e.name === "DATABASE_URL");
|
|
51
|
+
expect(db?.kind).toBe("invalid");
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
it("accepts postgres:// and postgresql:// for DATABASE_URL", () => {
|
|
@@ -57,17 +62,14 @@ describe("frameworkCoreEnvSchema", () => {
|
|
|
57
62
|
});
|
|
58
63
|
|
|
59
64
|
it("rejects a non-redis REDIS_URL and accepts redis:// + rediss://", () => {
|
|
60
|
-
|
|
65
|
+
const boot = expectBootError(() =>
|
|
61
66
|
parseEnv(frameworkCoreEnvSchema, {
|
|
62
67
|
DATABASE_URL: "postgres://localhost:5432/db",
|
|
63
68
|
REDIS_URL: "https://example.com/cache",
|
|
64
|
-
})
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const redis = (err as KumikoBootError).errors.find((e) => e.name === "REDIS_URL");
|
|
69
|
-
expect(redis?.kind).toBe("invalid");
|
|
70
|
-
}
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
const redis = boot.errors.find((e) => e.name === "REDIS_URL");
|
|
72
|
+
expect(redis?.kind).toBe("invalid");
|
|
71
73
|
for (const url of ["redis://localhost:6379", "rediss://localhost:6379"]) {
|
|
72
74
|
const env = parseEnv(frameworkCoreEnvSchema, {
|
|
73
75
|
DATABASE_URL: "postgres://localhost:5432/db",
|
|
@@ -78,18 +80,15 @@ describe("frameworkCoreEnvSchema", () => {
|
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
it("rejects an invalid PORT (non-numeric)", () => {
|
|
81
|
-
|
|
83
|
+
const boot = expectBootError(() =>
|
|
82
84
|
parseEnv(frameworkCoreEnvSchema, {
|
|
83
85
|
DATABASE_URL: "postgres://localhost:5432/db",
|
|
84
86
|
REDIS_URL: "redis://localhost:6379",
|
|
85
87
|
PORT: "not-a-port",
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const port = (err as KumikoBootError).errors.find((e) => e.name === "PORT");
|
|
91
|
-
expect(port?.kind).toBe("invalid");
|
|
92
|
-
}
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
const port = boot.errors.find((e) => e.name === "PORT");
|
|
91
|
+
expect(port?.kind).toBe("invalid");
|
|
93
92
|
});
|
|
94
93
|
|
|
95
94
|
it("KUMIKO_SKIP_ES_OPS accepts any string (matches runtime semantics)", () => {
|
|
@@ -112,17 +111,11 @@ describe("frameworkCoreEnvSchema", () => {
|
|
|
112
111
|
expect(sources["DATABASE_URL"]).toBe("framework-core");
|
|
113
112
|
expect(sources["PORT"]).toBe("framework-core");
|
|
114
113
|
expect(sources["STUDIO_ADMIN_EMAIL"]).toBe("app");
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const boot = err as KumikoBootError;
|
|
121
|
-
const formatted = boot.format();
|
|
122
|
-
expect(formatted).toContain("✗ DATABASE_URL (framework-core, required, missing)");
|
|
123
|
-
expect(formatted).toContain("✗ REDIS_URL (framework-core, required, missing)");
|
|
124
|
-
expect(formatted).toContain("✗ STUDIO_ADMIN_EMAIL (app, required, missing)");
|
|
125
|
-
}
|
|
114
|
+
const boot = expectBootError(() => parseEnv(schema, {}, { sources }));
|
|
115
|
+
const formatted = boot.format();
|
|
116
|
+
expect(formatted).toContain("✗ DATABASE_URL (framework-core, required, missing)");
|
|
117
|
+
expect(formatted).toContain("✗ REDIS_URL (framework-core, required, missing)");
|
|
118
|
+
expect(formatted).toContain("✗ STUDIO_ADMIN_EMAIL (app, required, missing)");
|
|
126
119
|
});
|
|
127
120
|
|
|
128
121
|
it("z.infer<typeof frameworkCoreEnvSchema> typechecks the expected shape", () => {
|
|
@@ -4,7 +4,6 @@ import { describe, expect, test } from "bun:test";
|
|
|
4
4
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { composeFeatures } from "../compose-features";
|
|
8
7
|
import { implicitAuthModeFeatureNames, resolveGeneratePath } from "../schema-check-core";
|
|
9
8
|
|
|
10
9
|
describe("resolveGeneratePath", () => {
|
|
@@ -43,10 +42,10 @@ describe("resolveGeneratePath", () => {
|
|
|
43
42
|
});
|
|
44
43
|
|
|
45
44
|
describe("implicitAuthModeFeatureNames", () => {
|
|
46
|
-
test("
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
//
|
|
45
|
+
test("liefert exakt das bundled-foundation-Set", () => {
|
|
46
|
+
// Bewusst hartkodierte Erwartung: ein Vergleich gegen composeFeatures
|
|
47
|
+
// wäre tautologisch (die Funktion IST dieser Ausdruck). Ändert sich das
|
|
48
|
+
// Foundation-Set, muss dieser Test bewusst angefasst werden.
|
|
50
49
|
expect([...implicitAuthModeFeatureNames()].sort()).toEqual([
|
|
51
50
|
"auth-email-password",
|
|
52
51
|
"config",
|
|
@@ -20,6 +20,7 @@ import { readFile, watch } from "node:fs/promises";
|
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
import { join, resolve } from "node:path";
|
|
22
22
|
import { type AuthRoutesConfig, generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
23
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
23
24
|
import {
|
|
24
25
|
buildAppSchema,
|
|
25
26
|
createSystemUser,
|
|
@@ -704,7 +705,7 @@ export async function createKumikoServer(
|
|
|
704
705
|
redis: stack.redis,
|
|
705
706
|
registry: stack.registry,
|
|
706
707
|
dispatchSystemWrite: ({ handlerQn, payload, tenantId }) =>
|
|
707
|
-
stack.dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [
|
|
708
|
+
stack.dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin])),
|
|
708
709
|
});
|
|
709
710
|
}
|
|
710
711
|
|
|
@@ -868,7 +869,9 @@ export async function createKumikoServer(
|
|
|
868
869
|
// staticDir-fallback") und macht r.httpRoute mit non-/api paths im
|
|
869
870
|
// dev-server symmetrisch zu prod.
|
|
870
871
|
if (
|
|
871
|
-
|
|
872
|
+
// HEAD mitnehmen — prod (runProdApp) fällt für GET UND HEAD auf die
|
|
873
|
+
// SPA zurück; ohne das liefert dev 404 wo prod 200 liefert.
|
|
874
|
+
(req.method === "GET" || req.method === "HEAD") &&
|
|
872
875
|
!url.pathname.startsWith("/api/") &&
|
|
873
876
|
!url.pathname.startsWith("/sse") &&
|
|
874
877
|
!url.pathname.includes(".")
|
package/src/kebab.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Segment-strict: rejects trailing/double hyphen so the name is a valid
|
|
2
|
+
// package-name + folder (`my-shop`, not `my-` or `my--shop`).
|
|
3
|
+
const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
4
|
+
|
|
5
|
+
export function isKebabSegment(value: string): boolean {
|
|
6
|
+
return KEBAB_RE.test(value);
|
|
7
|
+
}
|
package/src/run-prod-app.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { createSessionCallbacks } from "@cosmicdrift/kumiko-bundled-features/ses
|
|
|
41
41
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
42
42
|
import { UserQueries } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
43
43
|
import { createSseBroker, type SseBroker } from "@cosmicdrift/kumiko-framework/api";
|
|
44
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
44
45
|
import { createDbConnection, type DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
45
46
|
import {
|
|
46
47
|
buildAppSchema,
|
|
@@ -832,7 +833,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
832
833
|
entrypoint.dispatcher.write(
|
|
833
834
|
handlerQn,
|
|
834
835
|
payload,
|
|
835
|
-
createSystemUser(tenantId, [
|
|
836
|
+
createSystemUser(tenantId, [ROLES.SystemAdmin]),
|
|
836
837
|
),
|
|
837
838
|
});
|
|
838
839
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
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
|
+
import { isKebabSegment } from "./kebab";
|
|
17
18
|
|
|
18
19
|
export type ScaffoldAppFeatureOptions = {
|
|
19
20
|
/** kebab-case feature name (e.g. "product-catalog"). */
|
|
@@ -31,13 +32,10 @@ export type ScaffoldAppFeatureResult = {
|
|
|
31
32
|
readonly autoMounted: boolean;
|
|
32
33
|
};
|
|
33
34
|
|
|
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]+)*$/;
|
|
38
|
-
|
|
39
35
|
export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): ScaffoldAppFeatureResult {
|
|
40
|
-
|
|
36
|
+
// Segment-strict guard: a trailing/double hyphen (`product-`, `foo--bar`)
|
|
37
|
+
// would make kebabToCamel produce an invalid identifier.
|
|
38
|
+
if (!isKebabSegment(options.name)) {
|
|
41
39
|
throw new Error(
|
|
42
40
|
`scaffoldAppFeature: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`,
|
|
43
41
|
);
|
package/src/scaffold-app.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { join, resolve } from "node:path";
|
|
16
16
|
import { IndentationText, Project, VariableDeclarationKind } from "ts-morph";
|
|
17
|
+
import { isKebabSegment } from "./kebab";
|
|
17
18
|
|
|
18
19
|
export type ScaffoldAppOptions = {
|
|
19
20
|
/** kebab-case app name (e.g. "my-shop"). Becomes package-name + folder. */
|
|
@@ -35,12 +36,8 @@ export type ScaffoldAppResult = {
|
|
|
35
36
|
readonly appName: string;
|
|
36
37
|
};
|
|
37
38
|
|
|
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]+)*$/;
|
|
41
|
-
|
|
42
39
|
export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
|
|
43
|
-
if (!
|
|
40
|
+
if (!isKebabSegment(options.name)) {
|
|
44
41
|
throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
|
|
45
42
|
}
|
|
46
43
|
const cwd = options.cwd ?? process.cwd();
|
package/src/scaffold-deploy.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { isKebabSegment } from "./kebab";
|
|
13
14
|
|
|
14
15
|
export type ScaffoldDeployOptions = {
|
|
15
16
|
/** App name, kebab-case (e.g. "publicstatus", "kumiko-studio"). */
|
|
@@ -64,12 +65,8 @@ const TEMPLATE_FILES = [
|
|
|
64
65
|
{ template: "migrate-step.sh.template", output: "migrate-step.sh" },
|
|
65
66
|
] as const;
|
|
66
67
|
|
|
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]+)*$/;
|
|
70
|
-
|
|
71
68
|
export function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployResult {
|
|
72
|
-
if (!
|
|
69
|
+
if (!isKebabSegment(options.appName)) {
|
|
73
70
|
throw new Error(
|
|
74
71
|
`scaffoldDeploy: appName must be kebab-case (a-z, 0-9, -); got "${options.appName}"`,
|
|
75
72
|
);
|