@cosmicdrift/kumiko-dev-server 0.12.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +124 -0
- package/bin/kumiko-schema-check.ts +159 -0
- package/package.json +6 -4
- package/src/__tests__/env-schema.integration.ts +50 -0
- package/src/__tests__/scaffold-app-feature.test.ts +88 -0
- package/src/__tests__/scaffold-app.test.ts +104 -0
- package/src/__tests__/scaffold-deploy.test.ts +11 -0
- package/src/__tests__/walkthrough.integration.ts +118 -0
- package/src/build-server-bundle.ts +19 -1
- package/src/index.ts +7 -0
- package/src/run-prod-app.ts +35 -11
- package/src/scaffold-app-feature.ts +154 -0
- package/src/scaffold-app.ts +333 -0
- package/templates/deploy/Dockerfile.template +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,129 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-dev-server
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b8e1d48: scaffoldApp baut `src/run-config.ts` + `bin/main.ts` jetzt via ts-morph
|
|
8
|
+
(AST) statt template-strings. Selbes Tool wie scaffoldAppFeature →
|
|
9
|
+
ein konsistenter Mechanismus für generate + later modify. Plus:
|
|
10
|
+
ts-morph als explicit dependency aufgenommen (war bisher nur via
|
|
11
|
+
hoisted root-dep verfügbar; broken bei publish).
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- ce23d48: `walkthrough.integration.ts` — DX-3.1 walkthrough-snapshot-test. Pins
|
|
16
|
+
scaffoldApp + scaffoldAppFeature output gegen die Behauptungen in
|
|
17
|
+
docs.kumiko.so/en/walkthrough/. Catches doc-drift ohne actual
|
|
18
|
+
`bunx … && yarn install && bun run boot` CI-run.
|
|
19
|
+
|
|
20
|
+
5 Tests: file-list, auto-mount-diff, run-config text-content,
|
|
21
|
+
composeFeatures(includeBundled:true) = 7 features, bin/main auth.admin
|
|
22
|
+
stub.
|
|
23
|
+
|
|
24
|
+
- @cosmicdrift/kumiko-framework@0.14.0
|
|
25
|
+
- @cosmicdrift/kumiko-bundled-features@0.14.0
|
|
26
|
+
|
|
27
|
+
## 0.13.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- 7bd5c88: `KUMIKO_DRY_RUN_ENV=boot` mode for runProdApp — runs env-validation +
|
|
32
|
+
composeFeatures + validateBoot + createRegistry without DB/Redis
|
|
33
|
+
connect, exits with status 0 on success. Used by the
|
|
34
|
+
`samples/apps/use-all-bundled` smoke-app (Sprint 9.8 Phase C / Empfehlung
|
|
35
|
+
1 / canonical bug-catcher) and downstream by enterprise's
|
|
36
|
+
`use-all-features` mirror. Render-modes (human|json|pulumi|k8s|1)
|
|
37
|
+
behavior unchanged.
|
|
38
|
+
- 575752f: `scaffoldAppFeature` + `kumiko add feature <name>` — DX-2 aus DX-Roadmap.
|
|
39
|
+
Scaffolded ein neues Feature in `src/features/<name>/` einer bereits via
|
|
40
|
+
`kumiko new app` scaffolded App + **auto-mountet** es in `src/run-config.ts`
|
|
41
|
+
via ts-morph (import + `APP_FEATURES`-array-entry, idempotent).
|
|
42
|
+
|
|
43
|
+
User-Promise "defineFeature → nichts woanders eintragen" erfüllt für die
|
|
44
|
+
run-config-Seite. FEATURE_IMPORT_REGISTRY in drizzle/generate.ts ist
|
|
45
|
+
DX-4's Refactor — bei DX-1+DX-2-App noch nicht vorhanden.
|
|
46
|
+
|
|
47
|
+
Usage (in einer DX-1-gescaffoldeten App):
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
bunx kumiko add feature product-catalog
|
|
51
|
+
# → src/features/product-catalog/{feature.ts,index.ts}
|
|
52
|
+
# → src/run-config.ts auto-edited: import + APP_FEATURES-entry
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- 3d5e9ef: `kumiko-schema-check` CLI — Empfehlung 3 aus Sprint-9.8-Retro
|
|
56
|
+
(`luminous-watching-moler.md`). Diff't APP_FEATURES (runtime, aus
|
|
57
|
+
`src/run-config.ts`) gegen FEATURE_IMPORT_REGISTRY (statisch, aus
|
|
58
|
+
`drizzle/generate.ts`). Fängt Studio's 9.8-Drama: registry 18 features
|
|
59
|
+
hinter APP_FEATURES → migrations fehlten für mounted features.
|
|
60
|
+
|
|
61
|
+
Usage (im app-workspace):
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
bunx kumiko-schema-check
|
|
65
|
+
# or with custom paths:
|
|
66
|
+
bunx kumiko-schema-check --run-config src/run-config.ts --generate drizzle/generate.ts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Plus: 5 bundled-features hatten camelCase feature-names statt kebab-case
|
|
70
|
+
(Memory `feedback_kebab_aggregates`) — aufgedeckt durch den schema-check
|
|
71
|
+
gegen use-all-bundled. Fix: `channelEmail` → `channel-email`,
|
|
72
|
+
`channelInApp` → `channel-in-app`, `channelPush` → `channel-push`,
|
|
73
|
+
`rateLimiting` → `rate-limiting`, `rendererSimple` → `renderer-simple`.
|
|
74
|
+
|
|
75
|
+
Plus `CHANNEL_IN_APP_FEATURE` und `RATE_LIMITING_FEATURE` Konstanten
|
|
76
|
+
angepasst (waren intern auf camelCase, jetzt kebab-case).
|
|
77
|
+
|
|
78
|
+
- 46b84d0: `scaffoldApp` + `kumiko new app <name>` — DX-1.0 aus DX-Roadmap. Generiert
|
|
79
|
+
ein lauffähiges App-Skelett (package.json, tsconfig, run-config mit
|
|
80
|
+
secrets+sessions, bin/main.ts mit auth-admin-stub + deterministische
|
|
81
|
+
tenant-UUID, .env.example, README) in `<cwd>/<name>/`.
|
|
82
|
+
|
|
83
|
+
Boot-Pfad: `KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts` läuft ohne DB/Redis.
|
|
84
|
+
|
|
85
|
+
Held-back für spätere DX-Phasen: drizzle-setup (DX-1.1, blocked-by DX-4
|
|
86
|
+
auto-registry), Dockerfile (existing `kumiko init-deploy`), first feature
|
|
87
|
+
scaffold (existing `kumiko create` bzw. DX-2 `kumiko add feature`).
|
|
88
|
+
|
|
89
|
+
Usage:
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
bunx kumiko new app my-shop
|
|
93
|
+
cd my-shop && yarn install
|
|
94
|
+
cp .env.example .env # JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1 setzen
|
|
95
|
+
bun run boot # → boot validation OK
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Patch Changes
|
|
99
|
+
|
|
100
|
+
- 2bd60c1: `buildServerBundle` BUILD_ONLY_EXTERNALS erweitert um drizzle-kit's
|
|
101
|
+
dialect-resolver dynamic-imports: `@planetscale/database`, `@libsql/client`,
|
|
102
|
+
`better-sqlite3`, `@neondatabase/serverless`, `@vercel/postgres`, `mysql2`.
|
|
103
|
+
|
|
104
|
+
Aufgedeckt durch C1 Empfehlung 4 (bundle-smoke). Bisher schlug
|
|
105
|
+
`bun build` an dynamic-imports im drizzle-kit auch wenn der App nur
|
|
106
|
+
postgres nutzt. Externalisieren = build durchläuft + tree-shake wirft
|
|
107
|
+
die ungenutzten driver-modules eh raus.
|
|
108
|
+
|
|
109
|
+
- 8bfb284: Dockerfile.template setzt `YARN_ENABLE_SCRIPTS=false` im Build-Stage. Fixt msgpackr-extract native-build-Failures (ARM, CI) und generell jeden transitiven Native-Dep — der Build-Stage bundlet nur JS via `bun build`, Runtime-Native-Deps werden separat im Runtime-Stage via `bun install --production` installiert. Apps die bisher per-package-Workarounds via `dependenciesMeta.<pkg>.built=false` in der App-package.json brauchten (studio, enterprise) können diese Entries nach Upgrade auf diese dev-server-Version entfernen.
|
|
110
|
+
- cc0ddc0: `Dockerfile.template` emits an inline `start.sh` for createBunServer command-override target.
|
|
111
|
+
|
|
112
|
+
`infra/pulumi/bun-server.ts`'s `createBunServer` overrides the container command with `exec ./start.sh` after injecting DATABASE_URL from the init-container. Apps deployed via createBunServer crashed with `./start.sh: not found` until each one added a per-app `start.sh` in repo root (= studio's PR #22).
|
|
113
|
+
|
|
114
|
+
Now the Dockerfile-template emits the file inline (`RUN printf … > ./start.sh && chmod +x`). Apps no longer need to ship one — the runtime stage generates it. Apps that don't go through createBunServer's command-override still boot via the bottom CMD; start.sh is dead-code in that case.
|
|
115
|
+
|
|
116
|
+
- Updated dependencies [7f56b2f]
|
|
117
|
+
- Updated dependencies [68b8118]
|
|
118
|
+
- Updated dependencies [9121928]
|
|
119
|
+
- Updated dependencies [72518fa]
|
|
120
|
+
- Updated dependencies [0a00e7b]
|
|
121
|
+
- Updated dependencies [aca1443]
|
|
122
|
+
- Updated dependencies [c6cb96c]
|
|
123
|
+
- Updated dependencies [3d5e9ef]
|
|
124
|
+
- @cosmicdrift/kumiko-framework@0.13.0
|
|
125
|
+
- @cosmicdrift/kumiko-bundled-features@0.13.0
|
|
126
|
+
|
|
3
127
|
## 0.12.2
|
|
4
128
|
|
|
5
129
|
### Patch Changes
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// biome-ignore-all lint/suspicious/noConsole: CLI-Script, console ist Feature.
|
|
3
|
+
//
|
|
4
|
+
// kumiko schema check — Empfehlung 3 aus Sprint-9.8-Retro
|
|
5
|
+
// (luminous-watching-moler.md). Diff't APP_FEATURES (runtime, aus
|
|
6
|
+
// `src/run-config.ts`) gegen FEATURE_IMPORT_REGISTRY (statisch, aus
|
|
7
|
+
// `drizzle/generate.ts`). Catches:
|
|
8
|
+
//
|
|
9
|
+
// 1. Mount-without-registry: ein neues feature in APP_FEATURES ohne
|
|
10
|
+
// Entry in FEATURE_IMPORT_REGISTRY. Resultiert in Schema-Drift:
|
|
11
|
+
// Runtime mountet feature, Migration kennt seine Tabellen nicht.
|
|
12
|
+
// 2. Stale-registry: ein Entry in FEATURE_IMPORT_REGISTRY ohne
|
|
13
|
+
// matching mount in APP_FEATURES. Dead-code; im Schema entsteht
|
|
14
|
+
// eine Tabelle ohne Runtime-Konsument.
|
|
15
|
+
//
|
|
16
|
+
// Studio's 9.8-Drama: FEATURE_IMPORT_REGISTRY war 18 features hinter
|
|
17
|
+
// APP_FEATURES. Hätte mit diesem check eine Sekunde Lokal gefangen.
|
|
18
|
+
//
|
|
19
|
+
// Usage (aus dem app-workspace):
|
|
20
|
+
// bunx kumiko-schema-check
|
|
21
|
+
// # oder mit explicit pfaden:
|
|
22
|
+
// bunx kumiko-schema-check --run-config src/run-config.ts --generate drizzle/generate.ts
|
|
23
|
+
//
|
|
24
|
+
// Exit 0 wenn alles in sync, exit 1 wenn drift.
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { resolve } from "node:path";
|
|
28
|
+
|
|
29
|
+
type Args = {
|
|
30
|
+
readonly runConfigPath: string;
|
|
31
|
+
readonly generatePath: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv: readonly string[]): Args {
|
|
35
|
+
const cwd = process.cwd();
|
|
36
|
+
let runConfigPath = resolve(cwd, "src/run-config.ts");
|
|
37
|
+
let generatePath = resolve(cwd, "drizzle/generate.ts");
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const flag = argv[i];
|
|
40
|
+
const value = argv[i + 1];
|
|
41
|
+
if (flag === "--run-config" && value) {
|
|
42
|
+
runConfigPath = resolve(cwd, value);
|
|
43
|
+
i++;
|
|
44
|
+
} else if (flag === "--generate" && value) {
|
|
45
|
+
generatePath = resolve(cwd, value);
|
|
46
|
+
i++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { runConfigPath, generatePath };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readRegistryFeatures(generateSrc: string): Set<string> {
|
|
53
|
+
// Match object-keys mit Discriminator `kind: "factory" | "named"`.
|
|
54
|
+
// Quoted ("billing-foundation") oder unquoted (config, user) — beides
|
|
55
|
+
// ist gültig in JS. Pattern aus use-all-bundled/scripts/check-coverage.ts
|
|
56
|
+
// dupliziert hier weil per-app-CLI nicht von sample-script lesen darf.
|
|
57
|
+
const re = /(?:"([a-z][a-z0-9-]*)"|([a-z][a-z0-9]*)):\s*\{\s*kind:\s*"(factory|named)"/g;
|
|
58
|
+
const out = new Set<string>();
|
|
59
|
+
for (const m of generateSrc.matchAll(re)) {
|
|
60
|
+
const name = m[1] ?? m[2];
|
|
61
|
+
if (name) out.add(name);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function readMountedFeatures(runConfigPath: string): Promise<Set<string>> {
|
|
67
|
+
const mod = (await import(runConfigPath)) as {
|
|
68
|
+
APP_FEATURES?: ReadonlyArray<{ name: string }>;
|
|
69
|
+
HAS_AUTH?: boolean;
|
|
70
|
+
};
|
|
71
|
+
if (!mod.APP_FEATURES) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`kumiko-schema-check: ${runConfigPath} hat kein 'APP_FEATURES' export. ` +
|
|
74
|
+
`Convention: 'export const APP_FEATURES = [...] as const'.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
// composeFeatures auto-prepends config + user + tenant + auth-email-
|
|
78
|
+
// password im auth-mode. Wenn HAS_AUTH=true (oder default), die 4
|
|
79
|
+
// implicit-mounted features auch in die Set tun damit der Diff nicht
|
|
80
|
+
// false-positive "auth-email-password is mounted but no registry-entry"
|
|
81
|
+
// produziert. Studio/use-all-bundled HAS_AUTH=true ist convention.
|
|
82
|
+
const set = new Set<string>();
|
|
83
|
+
for (const f of mod.APP_FEATURES) {
|
|
84
|
+
set.add(f.name);
|
|
85
|
+
}
|
|
86
|
+
if (mod.HAS_AUTH ?? true) {
|
|
87
|
+
set.add("config");
|
|
88
|
+
set.add("user");
|
|
89
|
+
set.add("tenant");
|
|
90
|
+
set.add("auth-email-password");
|
|
91
|
+
}
|
|
92
|
+
return set;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function main(): Promise<void> {
|
|
96
|
+
const args = parseArgs(process.argv.slice(2));
|
|
97
|
+
|
|
98
|
+
if (!existsSync(args.runConfigPath)) {
|
|
99
|
+
console.error(`✗ run-config not found: ${args.runConfigPath}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
if (!existsSync(args.generatePath)) {
|
|
103
|
+
console.error(`✗ generate not found: ${args.generatePath}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const registry = readRegistryFeatures(readFileSync(args.generatePath, "utf-8"));
|
|
108
|
+
const mounted = await readMountedFeatures(args.runConfigPath);
|
|
109
|
+
|
|
110
|
+
// Mounted but not in registry → schema-drift (runtime ↔ migration mismatch).
|
|
111
|
+
const mountedWithoutEntry: string[] = [];
|
|
112
|
+
for (const name of mounted) {
|
|
113
|
+
if (!registry.has(name)) mountedWithoutEntry.push(name);
|
|
114
|
+
}
|
|
115
|
+
// In registry but not mounted → stale entry (dead schema-mapping).
|
|
116
|
+
const staleEntries: string[] = [];
|
|
117
|
+
for (const name of registry) {
|
|
118
|
+
if (!mounted.has(name)) staleEntries.push(name);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let ok = true;
|
|
122
|
+
|
|
123
|
+
if (mountedWithoutEntry.length > 0) {
|
|
124
|
+
console.error(
|
|
125
|
+
`\n✗ ${mountedWithoutEntry.length} feature(s) mounted in APP_FEATURES but NOT in FEATURE_IMPORT_REGISTRY:`,
|
|
126
|
+
);
|
|
127
|
+
for (const name of mountedWithoutEntry.sort()) {
|
|
128
|
+
console.error(` - ${name}`);
|
|
129
|
+
}
|
|
130
|
+
console.error(
|
|
131
|
+
"\n Action: in drizzle/generate.ts FEATURE_IMPORT_REGISTRY den Eintrag ergänzen,",
|
|
132
|
+
);
|
|
133
|
+
console.error(" damit Schema-Generator + Migration die feature-Tabellen kennt.");
|
|
134
|
+
ok = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (staleEntries.length > 0) {
|
|
138
|
+
console.error(
|
|
139
|
+
`\n✗ ${staleEntries.length} stale FEATURE_IMPORT_REGISTRY entry/entries (kein matching mount):`,
|
|
140
|
+
);
|
|
141
|
+
for (const name of staleEntries.sort()) {
|
|
142
|
+
console.error(` - ${name}`);
|
|
143
|
+
}
|
|
144
|
+
console.error("\n Action: entry aus drizzle/generate.ts FEATURE_IMPORT_REGISTRY entfernen,");
|
|
145
|
+
console.error(" oder das feature in src/run-config.ts mounten.");
|
|
146
|
+
ok = false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (ok) {
|
|
150
|
+
console.log(
|
|
151
|
+
`✓ schema check: ${mounted.size} mounted ↔ ${registry.size} registry entries, no drift`,
|
|
152
|
+
);
|
|
153
|
+
process.exit(0);
|
|
154
|
+
} else {
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
await main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -45,11 +45,13 @@
|
|
|
45
45
|
},
|
|
46
46
|
"bin": {
|
|
47
47
|
"kumiko-build": "./bin/kumiko-build.ts",
|
|
48
|
-
"kumiko-dev": "./bin/kumiko-dev.ts"
|
|
48
|
+
"kumiko-dev": "./bin/kumiko-dev.ts",
|
|
49
|
+
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
52
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
52
|
+
"@cosmicdrift/kumiko-bundled-features": "0.14.0",
|
|
53
|
+
"@cosmicdrift/kumiko-framework": "0.14.0",
|
|
54
|
+
"ts-morph": "^28.0.0"
|
|
53
55
|
},
|
|
54
56
|
"publishConfig": {
|
|
55
57
|
"registry": "https://registry.npmjs.org",
|
|
@@ -182,4 +182,54 @@ describe("runProdApp envSchema integration", () => {
|
|
|
182
182
|
console.log = realLog;
|
|
183
183
|
}
|
|
184
184
|
});
|
|
185
|
+
|
|
186
|
+
it("KUMIKO_DRY_RUN_ENV=boot runs validators + exits before DB-connect", async () => {
|
|
187
|
+
const logs: string[] = [];
|
|
188
|
+
const realLog = console.log;
|
|
189
|
+
console.log = (...args: unknown[]) => {
|
|
190
|
+
logs.push(args.map(String).join(" "));
|
|
191
|
+
};
|
|
192
|
+
try {
|
|
193
|
+
const handle = await runProdApp({
|
|
194
|
+
features: [secretsFeature, authFeature],
|
|
195
|
+
envSchema: composed,
|
|
196
|
+
envSource: {
|
|
197
|
+
KUMIKO_DRY_RUN_ENV: "boot",
|
|
198
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
199
|
+
JWT_SECRET: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
|
200
|
+
STUDIO_ADMIN_EMAIL: "ops@example.com",
|
|
201
|
+
DATABASE_URL: "postgres://dummy:dummy@127.0.0.1:1/dummy",
|
|
202
|
+
REDIS_URL: "redis://127.0.0.1:1",
|
|
203
|
+
},
|
|
204
|
+
migrations: false,
|
|
205
|
+
});
|
|
206
|
+
expect(logs.join("\n")).toContain("boot validation OK");
|
|
207
|
+
expect(handle).toBeDefined();
|
|
208
|
+
} finally {
|
|
209
|
+
console.log = realLog;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("KUMIKO_DRY_RUN_ENV=boot still aggregates env-errors before exit", async () => {
|
|
214
|
+
let captured: KumikoBootError | undefined;
|
|
215
|
+
try {
|
|
216
|
+
await runProdApp({
|
|
217
|
+
features: [secretsFeature, authFeature],
|
|
218
|
+
envSchema: composed,
|
|
219
|
+
envSource: {
|
|
220
|
+
KUMIKO_DRY_RUN_ENV: "boot",
|
|
221
|
+
JWT_SECRET: "short",
|
|
222
|
+
STUDIO_ADMIN_EMAIL: "not-an-email",
|
|
223
|
+
},
|
|
224
|
+
bootErrorReporter: (err) => {
|
|
225
|
+
captured = err;
|
|
226
|
+
throw err;
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
expect(err).toBeInstanceOf(KumikoBootError);
|
|
231
|
+
}
|
|
232
|
+
expect(captured).toBeDefined();
|
|
233
|
+
expect(captured!.errors.length).toBeGreaterThanOrEqual(3);
|
|
234
|
+
});
|
|
185
235
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// scaffoldAppFeature unit-tests (DX-2).
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
7
|
+
import { scaffoldApp } from "../scaffold-app";
|
|
8
|
+
import { scaffoldAppFeature } from "../scaffold-app-feature";
|
|
9
|
+
|
|
10
|
+
describe("scaffoldAppFeature", () => {
|
|
11
|
+
let tmp: string;
|
|
12
|
+
let appRoot: string;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmp = mkdtempSync(join(tmpdir(), "scaffold-app-feature-"));
|
|
15
|
+
appRoot = join(tmp, "my-shop");
|
|
16
|
+
scaffoldApp({ name: "my-shop", destination: appRoot });
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("scaffolds src/features/<name>/feature.ts + index.ts", () => {
|
|
23
|
+
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
24
|
+
expect(result.featureName).toBe("product-catalog");
|
|
25
|
+
expect(result.files).toEqual([
|
|
26
|
+
"src/features/product-catalog/feature.ts",
|
|
27
|
+
"src/features/product-catalog/index.ts",
|
|
28
|
+
]);
|
|
29
|
+
expect(existsSync(join(appRoot, "src/features/product-catalog/feature.ts"))).toBe(true);
|
|
30
|
+
expect(existsSync(join(appRoot, "src/features/product-catalog/index.ts"))).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("feature.ts uses kebab-name + camelCase variable", () => {
|
|
34
|
+
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
35
|
+
const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
|
|
36
|
+
expect(feature).toContain(`defineFeature("product-catalog"`);
|
|
37
|
+
expect(feature).toContain("export const productCatalogFeature");
|
|
38
|
+
expect(feature).toContain('r.entity("product-catalog-item"');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("auto-mounts in src/run-config.ts (import + APP_FEATURES entry)", () => {
|
|
42
|
+
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
43
|
+
expect(result.autoMounted).toBe(true);
|
|
44
|
+
const runConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
|
|
45
|
+
expect(runConfig).toContain(
|
|
46
|
+
`import { productCatalogFeature } from "./features/product-catalog";`,
|
|
47
|
+
);
|
|
48
|
+
expect(runConfig).toContain("productCatalogFeature");
|
|
49
|
+
// APP_FEATURES still ends with `as const`
|
|
50
|
+
expect(runConfig).toMatch(/\]\s*as const;/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("idempotent: re-mount of existing feature is a no-op", () => {
|
|
54
|
+
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
55
|
+
const firstRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
|
|
56
|
+
// Now re-mount (second feature creates dir-already-exists error;
|
|
57
|
+
// we instead simulate "feature dir exists, only run-config dance").
|
|
58
|
+
// → Real DX-2 flow: scaffold fails on dir-exists; manual remount
|
|
59
|
+
// would call mountInRunConfig directly. Test the mount-side
|
|
60
|
+
// idempotency by triggering a second feature with a different
|
|
61
|
+
// name and asserting the first import stays exactly once.
|
|
62
|
+
scaffoldAppFeature({ name: "billing", appRoot });
|
|
63
|
+
const secondRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
|
|
64
|
+
expect(secondRunConfig).toContain("productCatalogFeature");
|
|
65
|
+
expect(secondRunConfig).toContain("billingFeature");
|
|
66
|
+
// Count: each feature-import appears exactly once.
|
|
67
|
+
const occurrences = secondRunConfig.match(/productCatalogFeature/g) ?? [];
|
|
68
|
+
expect(occurrences.length).toBe(2); // 1 import + 1 array-entry
|
|
69
|
+
expect(firstRunConfig.length).toBeLessThan(secondRunConfig.length);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("rejects non-kebab-case", () => {
|
|
73
|
+
expect(() => scaffoldAppFeature({ name: "ProductCatalog", appRoot })).toThrow(/kebab-case/);
|
|
74
|
+
expect(() => scaffoldAppFeature({ name: "product_catalog", appRoot })).toThrow(/kebab-case/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("refuses to overwrite existing feature dir", () => {
|
|
78
|
+
scaffoldAppFeature({ name: "billing", appRoot });
|
|
79
|
+
expect(() => scaffoldAppFeature({ name: "billing", appRoot })).toThrow(/already exists/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("autoMounted=false when run-config.ts is missing", () => {
|
|
83
|
+
const emptyRoot = join(tmp, "no-app");
|
|
84
|
+
expect(() => scaffoldAppFeature({ name: "foo", appRoot: emptyRoot })).not.toThrow();
|
|
85
|
+
const result = scaffoldAppFeature({ name: "bar", appRoot: emptyRoot });
|
|
86
|
+
expect(result.autoMounted).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// scaffoldApp unit-tests (DX-1.0).
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
7
|
+
import { scaffoldApp } from "../scaffold-app";
|
|
8
|
+
|
|
9
|
+
describe("scaffoldApp", () => {
|
|
10
|
+
let tmp: string;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmp = mkdtempSync(join(tmpdir(), "scaffold-app-"));
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("scaffolds 6 files into <cwd>/<name>", () => {
|
|
19
|
+
const dest = join(tmp, "my-shop");
|
|
20
|
+
const result = scaffoldApp({ name: "my-shop", destination: dest });
|
|
21
|
+
|
|
22
|
+
expect(result.appName).toBe("my-shop");
|
|
23
|
+
expect(result.destination).toBe(dest);
|
|
24
|
+
expect(result.files).toEqual([
|
|
25
|
+
"package.json",
|
|
26
|
+
"tsconfig.json",
|
|
27
|
+
"src/run-config.ts",
|
|
28
|
+
"bin/main.ts",
|
|
29
|
+
".env.example",
|
|
30
|
+
"README.md",
|
|
31
|
+
]);
|
|
32
|
+
for (const f of result.files) {
|
|
33
|
+
expect(existsSync(join(dest, f))).toBe(true);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("package.json has @cosmicdrift/* deps with version pin", () => {
|
|
38
|
+
const dest = join(tmp, "my-shop");
|
|
39
|
+
scaffoldApp({ name: "my-shop", destination: dest, frameworkVersion: "^0.13.0" });
|
|
40
|
+
|
|
41
|
+
const pkg = JSON.parse(readFileSync(join(dest, "package.json"), "utf-8")) as {
|
|
42
|
+
name: string;
|
|
43
|
+
dependencies: Record<string, string>;
|
|
44
|
+
scripts: Record<string, string>;
|
|
45
|
+
};
|
|
46
|
+
expect(pkg.name).toBe("my-shop");
|
|
47
|
+
expect(pkg.dependencies["@cosmicdrift/kumiko-bundled-features"]).toBe("^0.13.0");
|
|
48
|
+
expect(pkg.dependencies["@cosmicdrift/kumiko-dev-server"]).toBe("^0.13.0");
|
|
49
|
+
expect(pkg.dependencies["@cosmicdrift/kumiko-framework"]).toBe("^0.13.0");
|
|
50
|
+
expect(pkg.scripts["boot"]).toContain("KUMIKO_DRY_RUN_ENV=boot");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("bin/main.ts contains runProdApp + auth.admin stub", () => {
|
|
54
|
+
const dest = join(tmp, "my-shop");
|
|
55
|
+
scaffoldApp({ name: "my-shop", destination: dest });
|
|
56
|
+
|
|
57
|
+
const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
|
|
58
|
+
expect(main).toContain("runProdApp");
|
|
59
|
+
expect(main).toContain("auth: {");
|
|
60
|
+
expect(main).toContain("admin@my-shop.local");
|
|
61
|
+
expect(main).toContain('tenantKey: "my-shop"');
|
|
62
|
+
// Tenant-ID is a valid UUID-v4 format (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx).
|
|
63
|
+
expect(main).toMatch(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("src/run-config.ts mounts secrets + sessions as foundation", () => {
|
|
67
|
+
const dest = join(tmp, "my-shop");
|
|
68
|
+
scaffoldApp({ name: "my-shop", destination: dest });
|
|
69
|
+
|
|
70
|
+
const runConfig = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
|
|
71
|
+
expect(runConfig).toContain("createSecretsFeature()");
|
|
72
|
+
expect(runConfig).toContain("createSessionsFeature()");
|
|
73
|
+
expect(runConfig).toContain("export const APP_FEATURES");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("rejects non-kebab-case names", () => {
|
|
77
|
+
expect(() => scaffoldApp({ name: "MyShop", destination: tmp })).toThrow(/kebab-case/);
|
|
78
|
+
expect(() => scaffoldApp({ name: "my_shop", destination: tmp })).toThrow(/kebab-case/);
|
|
79
|
+
expect(() => scaffoldApp({ name: "0shop", destination: tmp })).toThrow(/kebab-case/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("refuses to overwrite existing destination", () => {
|
|
83
|
+
const dest = join(tmp, "existing");
|
|
84
|
+
scaffoldApp({ name: "existing", destination: dest });
|
|
85
|
+
expect(() => scaffoldApp({ name: "existing", destination: dest })).toThrow(/already exists/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("deterministic tenantId for same name (reproducible boots)", () => {
|
|
89
|
+
const a = join(tmp, "a");
|
|
90
|
+
const b = join(tmp, "b");
|
|
91
|
+
scaffoldApp({ name: "stable", destination: a });
|
|
92
|
+
scaffoldApp({ name: "stable", destination: b });
|
|
93
|
+
const mainA = readFileSync(join(a, "bin/main.ts"), "utf-8");
|
|
94
|
+
const mainB = readFileSync(join(b, "bin/main.ts"), "utf-8");
|
|
95
|
+
const uuidA = mainA.match(
|
|
96
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/,
|
|
97
|
+
)?.[0];
|
|
98
|
+
const uuidB = mainB.match(
|
|
99
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/,
|
|
100
|
+
)?.[0];
|
|
101
|
+
expect(uuidA).toBeDefined();
|
|
102
|
+
expect(uuidA).toBe(uuidB);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -56,6 +56,17 @@ describe("scaffoldDeploy", () => {
|
|
|
56
56
|
expect(migrate).toContain("ghcr.io/cosmicdriftgamestudio/minimal:latest");
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it("Dockerfile emits inline start.sh (createBunServer command-override target)", () => {
|
|
60
|
+
scaffoldDeploy({ appName: "boot-target", destination: tmp });
|
|
61
|
+
const dockerfile = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
|
|
62
|
+
// Inline RUN that creates a start.sh inside the runtime image.
|
|
63
|
+
// bun-server.ts's createBunServer overrides the container command with
|
|
64
|
+
// `exec ./start.sh` after injecting DATABASE_URL; without this line the
|
|
65
|
+
// pod exited 127. Memory: `feedback_audit_drift_root_cause_now`.
|
|
66
|
+
expect(dockerfile).toContain("> ./start.sh && chmod +x ./start.sh");
|
|
67
|
+
expect(dockerfile).toContain("exec bun run server.js");
|
|
68
|
+
});
|
|
69
|
+
|
|
59
70
|
it("skips existing files by default", () => {
|
|
60
71
|
const existing = join(tmp, "deploy");
|
|
61
72
|
scaffoldDeploy({ appName: "first", destination: tmp });
|