@cosmicdrift/kumiko-dev-server 0.12.2 → 0.13.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 +100 -0
- package/bin/kumiko-schema-check.ts +159 -0
- package/package.json +5 -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/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 +270 -0
- package/templates/deploy/Dockerfile.template +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,105 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-dev-server
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7bd5c88: `KUMIKO_DRY_RUN_ENV=boot` mode for runProdApp — runs env-validation +
|
|
8
|
+
composeFeatures + validateBoot + createRegistry without DB/Redis
|
|
9
|
+
connect, exits with status 0 on success. Used by the
|
|
10
|
+
`samples/apps/use-all-bundled` smoke-app (Sprint 9.8 Phase C / Empfehlung
|
|
11
|
+
1 / canonical bug-catcher) and downstream by enterprise's
|
|
12
|
+
`use-all-features` mirror. Render-modes (human|json|pulumi|k8s|1)
|
|
13
|
+
behavior unchanged.
|
|
14
|
+
- 575752f: `scaffoldAppFeature` + `kumiko add feature <name>` — DX-2 aus DX-Roadmap.
|
|
15
|
+
Scaffolded ein neues Feature in `src/features/<name>/` einer bereits via
|
|
16
|
+
`kumiko new app` scaffolded App + **auto-mountet** es in `src/run-config.ts`
|
|
17
|
+
via ts-morph (import + `APP_FEATURES`-array-entry, idempotent).
|
|
18
|
+
|
|
19
|
+
User-Promise "defineFeature → nichts woanders eintragen" erfüllt für die
|
|
20
|
+
run-config-Seite. FEATURE_IMPORT_REGISTRY in drizzle/generate.ts ist
|
|
21
|
+
DX-4's Refactor — bei DX-1+DX-2-App noch nicht vorhanden.
|
|
22
|
+
|
|
23
|
+
Usage (in einer DX-1-gescaffoldeten App):
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
bunx kumiko add feature product-catalog
|
|
27
|
+
# → src/features/product-catalog/{feature.ts,index.ts}
|
|
28
|
+
# → src/run-config.ts auto-edited: import + APP_FEATURES-entry
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
- 3d5e9ef: `kumiko-schema-check` CLI — Empfehlung 3 aus Sprint-9.8-Retro
|
|
32
|
+
(`luminous-watching-moler.md`). Diff't APP_FEATURES (runtime, aus
|
|
33
|
+
`src/run-config.ts`) gegen FEATURE_IMPORT_REGISTRY (statisch, aus
|
|
34
|
+
`drizzle/generate.ts`). Fängt Studio's 9.8-Drama: registry 18 features
|
|
35
|
+
hinter APP_FEATURES → migrations fehlten für mounted features.
|
|
36
|
+
|
|
37
|
+
Usage (im app-workspace):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
bunx kumiko-schema-check
|
|
41
|
+
# or with custom paths:
|
|
42
|
+
bunx kumiko-schema-check --run-config src/run-config.ts --generate drizzle/generate.ts
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Plus: 5 bundled-features hatten camelCase feature-names statt kebab-case
|
|
46
|
+
(Memory `feedback_kebab_aggregates`) — aufgedeckt durch den schema-check
|
|
47
|
+
gegen use-all-bundled. Fix: `channelEmail` → `channel-email`,
|
|
48
|
+
`channelInApp` → `channel-in-app`, `channelPush` → `channel-push`,
|
|
49
|
+
`rateLimiting` → `rate-limiting`, `rendererSimple` → `renderer-simple`.
|
|
50
|
+
|
|
51
|
+
Plus `CHANNEL_IN_APP_FEATURE` und `RATE_LIMITING_FEATURE` Konstanten
|
|
52
|
+
angepasst (waren intern auf camelCase, jetzt kebab-case).
|
|
53
|
+
|
|
54
|
+
- 46b84d0: `scaffoldApp` + `kumiko new app <name>` — DX-1.0 aus DX-Roadmap. Generiert
|
|
55
|
+
ein lauffähiges App-Skelett (package.json, tsconfig, run-config mit
|
|
56
|
+
secrets+sessions, bin/main.ts mit auth-admin-stub + deterministische
|
|
57
|
+
tenant-UUID, .env.example, README) in `<cwd>/<name>/`.
|
|
58
|
+
|
|
59
|
+
Boot-Pfad: `KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts` läuft ohne DB/Redis.
|
|
60
|
+
|
|
61
|
+
Held-back für spätere DX-Phasen: drizzle-setup (DX-1.1, blocked-by DX-4
|
|
62
|
+
auto-registry), Dockerfile (existing `kumiko init-deploy`), first feature
|
|
63
|
+
scaffold (existing `kumiko create` bzw. DX-2 `kumiko add feature`).
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
bunx kumiko new app my-shop
|
|
69
|
+
cd my-shop && yarn install
|
|
70
|
+
cp .env.example .env # JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1 setzen
|
|
71
|
+
bun run boot # → boot validation OK
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Patch Changes
|
|
75
|
+
|
|
76
|
+
- 2bd60c1: `buildServerBundle` BUILD_ONLY_EXTERNALS erweitert um drizzle-kit's
|
|
77
|
+
dialect-resolver dynamic-imports: `@planetscale/database`, `@libsql/client`,
|
|
78
|
+
`better-sqlite3`, `@neondatabase/serverless`, `@vercel/postgres`, `mysql2`.
|
|
79
|
+
|
|
80
|
+
Aufgedeckt durch C1 Empfehlung 4 (bundle-smoke). Bisher schlug
|
|
81
|
+
`bun build` an dynamic-imports im drizzle-kit auch wenn der App nur
|
|
82
|
+
postgres nutzt. Externalisieren = build durchläuft + tree-shake wirft
|
|
83
|
+
die ungenutzten driver-modules eh raus.
|
|
84
|
+
|
|
85
|
+
- 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.
|
|
86
|
+
- cc0ddc0: `Dockerfile.template` emits an inline `start.sh` for createBunServer command-override target.
|
|
87
|
+
|
|
88
|
+
`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).
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
- Updated dependencies [7f56b2f]
|
|
93
|
+
- Updated dependencies [68b8118]
|
|
94
|
+
- Updated dependencies [9121928]
|
|
95
|
+
- Updated dependencies [72518fa]
|
|
96
|
+
- Updated dependencies [0a00e7b]
|
|
97
|
+
- Updated dependencies [aca1443]
|
|
98
|
+
- Updated dependencies [c6cb96c]
|
|
99
|
+
- Updated dependencies [3d5e9ef]
|
|
100
|
+
- @cosmicdrift/kumiko-framework@0.13.0
|
|
101
|
+
- @cosmicdrift/kumiko-bundled-features@0.13.0
|
|
102
|
+
|
|
3
103
|
## 0.12.2
|
|
4
104
|
|
|
5
105
|
### 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.13.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,12 @@
|
|
|
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.13.0",
|
|
53
|
+
"@cosmicdrift/kumiko-framework": "0.13.0"
|
|
53
54
|
},
|
|
54
55
|
"publishConfig": {
|
|
55
56
|
"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 });
|
|
@@ -82,7 +82,25 @@ const RUNTIME_EXTERNALS = [
|
|
|
82
82
|
// Markierung scheitert bun build an dynamic-imports (z.B. drizzle-kit →
|
|
83
83
|
// @libsql/client). Tree-Shake wirft sie eh aus dem Bundle — der Marker
|
|
84
84
|
// schaltet nur das resolution-during-build ab. NICHT in runtime-deps.
|
|
85
|
-
|
|
85
|
+
//
|
|
86
|
+
// drizzle-kit's dialect-resolver macht dynamic-imports zu allen DB-driver-
|
|
87
|
+
// packages (planetscale/libsql/sqlite/neon/vercel/mysql2). Wir nutzen nur
|
|
88
|
+
// postgres → diese werden never-loaded zur Runtime, aber der Bundler will
|
|
89
|
+
// sie resolven. Aufgedeckt durch C1 Empfehlung 4 (bundle-smoke).
|
|
90
|
+
const BUILD_ONLY_EXTERNALS = [
|
|
91
|
+
"meilisearch",
|
|
92
|
+
"pino",
|
|
93
|
+
"pino-pretty",
|
|
94
|
+
"@aws-sdk/*",
|
|
95
|
+
"@planetscale/database",
|
|
96
|
+
"@libsql/client",
|
|
97
|
+
"better-sqlite3",
|
|
98
|
+
"@neondatabase/serverless",
|
|
99
|
+
"@vercel/postgres",
|
|
100
|
+
"mysql2",
|
|
101
|
+
// ink (kumiko-tui) hat react-devtools-core als dev-only transitive import.
|
|
102
|
+
"react-devtools-core",
|
|
103
|
+
] as const;
|
|
86
104
|
|
|
87
105
|
export type BuildServerBundleOptions = {
|
|
88
106
|
/** App-Root. Default: process.cwd(). */
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,13 @@ export type {
|
|
|
54
54
|
SignupSetup,
|
|
55
55
|
} from "./run-prod-app";
|
|
56
56
|
export { runProdApp } from "./run-prod-app";
|
|
57
|
+
export type { ScaffoldAppOptions, ScaffoldAppResult } from "./scaffold-app";
|
|
58
|
+
export { scaffoldApp } from "./scaffold-app";
|
|
59
|
+
export type {
|
|
60
|
+
ScaffoldAppFeatureOptions,
|
|
61
|
+
ScaffoldAppFeatureResult,
|
|
62
|
+
} from "./scaffold-app-feature";
|
|
63
|
+
export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature";
|
|
57
64
|
export type {
|
|
58
65
|
ScaffoldDeployOptions,
|
|
59
66
|
ScaffoldDeployResult,
|
package/src/run-prod-app.ts
CHANGED
|
@@ -126,23 +126,28 @@ function readEnv(name: string): string | undefined {
|
|
|
126
126
|
return value === undefined || value === "" ? undefined : value;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
129
|
+
// `boot` is the C1 smoke-test path — validators run, no DB/Redis connect,
|
|
130
|
+
// exit after registry-build. Render-modes (human|json|pulumi|k8s|1)
|
|
131
|
+
// inspect the env-schema and exit before any feature wiring.
|
|
132
|
+
type RunMode = DryRunMode | "boot";
|
|
133
|
+
|
|
134
|
+
function parseRunMode(raw: string | undefined): RunMode | null {
|
|
134
135
|
if (!raw) return null;
|
|
135
136
|
const v = raw.toLowerCase();
|
|
136
137
|
if (v === "1" || v === "true" || v === "human") return "human";
|
|
137
|
-
if (v === "json" || v === "pulumi" || v === "k8s") return v;
|
|
138
|
+
if (v === "json" || v === "pulumi" || v === "k8s" || v === "boot") return v;
|
|
138
139
|
// biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
|
|
139
140
|
console.warn(
|
|
140
141
|
`[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
|
|
141
|
-
`(expected 1|human|json|pulumi|k8s); continuing with normal boot.`,
|
|
142
|
+
`(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`,
|
|
142
143
|
);
|
|
143
144
|
return null;
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
function isRenderMode(mode: RunMode | null): mode is DryRunMode {
|
|
148
|
+
return mode !== null && mode !== "boot";
|
|
149
|
+
}
|
|
150
|
+
|
|
146
151
|
function defaultBootErrorReporter(err: KumikoBootError): never {
|
|
147
152
|
// biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
|
|
148
153
|
console.error(err.format());
|
|
@@ -485,13 +490,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
485
490
|
// at parse-time before the polyfill loads. Plain strings + .regex /
|
|
486
491
|
// .min / .email / .url cover every env-var shape we've actually
|
|
487
492
|
// needed in 9.1's audit (37 references, 25 distinct vars).
|
|
493
|
+
const envSource = options.envSource ?? process.env;
|
|
494
|
+
const runMode = parseRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
|
|
488
495
|
if (options.envSchema) {
|
|
489
|
-
|
|
490
|
-
const dryRunMode = parseDryRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
|
|
491
|
-
if (dryRunMode !== null) {
|
|
496
|
+
if (isRenderMode(runMode)) {
|
|
492
497
|
// biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
|
|
493
498
|
console.log(
|
|
494
|
-
renderDryRun(options.envSchema,
|
|
499
|
+
renderDryRun(options.envSchema, runMode, {
|
|
495
500
|
...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
|
|
496
501
|
sources: options.envSchema.sources,
|
|
497
502
|
}),
|
|
@@ -504,6 +509,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
504
509
|
}
|
|
505
510
|
return makeDryRunHandle();
|
|
506
511
|
}
|
|
512
|
+
// boot-mode AND normal-boot both run env-validation. boot-mode wants
|
|
513
|
+
// a real env-check (all required vars present + schema-valid) before
|
|
514
|
+
// it asserts feature-wiring works.
|
|
507
515
|
try {
|
|
508
516
|
parseEnv(options.envSchema.schema, envSource, {
|
|
509
517
|
sources: options.envSchema.sources,
|
|
@@ -554,6 +562,22 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
554
562
|
validateBoot(features);
|
|
555
563
|
const registry = createRegistry(features);
|
|
556
564
|
|
|
565
|
+
// C1 boot-mode exit: validators ran, registry built, no DB/Redis
|
|
566
|
+
// operations executed yet (postgres.js + ioredis are lazy). Tear down
|
|
567
|
+
// the lazy clients so Bun doesn't keep them open, then exit / return.
|
|
568
|
+
if (runMode === "boot") {
|
|
569
|
+
// biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
|
|
570
|
+
console.log(
|
|
571
|
+
`[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
|
|
572
|
+
);
|
|
573
|
+
await closeDb();
|
|
574
|
+
redis.disconnect();
|
|
575
|
+
if (options.envSource === undefined) {
|
|
576
|
+
process.exit(0);
|
|
577
|
+
}
|
|
578
|
+
return makeDryRunHandle();
|
|
579
|
+
}
|
|
580
|
+
|
|
557
581
|
// Sprint-8a Tier-Composition auto-wire: scan features for a
|
|
558
582
|
// tenantTierResolver-extension. If found AND user didn't supply own
|
|
559
583
|
// effectiveFeatures, build the resolver here (db + registry are
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// scaffoldAppFeature — DX-2. Scaffolds a fresh feature inside an
|
|
2
|
+
// existing Kumiko-app workspace + auto-mounts it in src/run-config.ts.
|
|
3
|
+
//
|
|
4
|
+
// Sister to `scaffoldFeature` (which targets samples/recipes/ for the
|
|
5
|
+
// framework workspace). This one targets `src/features/<name>/` of an
|
|
6
|
+
// already-scaffolded app (output of `kumiko new app`).
|
|
7
|
+
//
|
|
8
|
+
// Auto-mount via ts-morph: opens src/run-config.ts, finds
|
|
9
|
+
// `export const APP_FEATURES = [...]`, prepends import + appends entry.
|
|
10
|
+
// User's promise "defineFeature → nichts woanders eintragen" is met
|
|
11
|
+
// for the run-config side. Drizzle FEATURE_IMPORT_REGISTRY is NOT
|
|
12
|
+
// touched here — DX-4 auto-discovery resolves that.
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
17
|
+
|
|
18
|
+
export type ScaffoldAppFeatureOptions = {
|
|
19
|
+
/** kebab-case feature name (e.g. "product-catalog"). */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** App workspace root. Defaults to cwd. */
|
|
22
|
+
readonly appRoot?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ScaffoldAppFeatureResult = {
|
|
26
|
+
readonly featureName: string;
|
|
27
|
+
readonly featureDir: string;
|
|
28
|
+
readonly files: readonly string[];
|
|
29
|
+
/** Whether src/run-config.ts was auto-mounted. False if run-config
|
|
30
|
+
* is missing — caller gets the scaffolded files but must hand-mount. */
|
|
31
|
+
readonly autoMounted: boolean;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
|
|
35
|
+
|
|
36
|
+
export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): ScaffoldAppFeatureResult {
|
|
37
|
+
if (!KEBAB_RE.test(options.name)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`scaffoldAppFeature: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const appRoot = resolve(options.appRoot ?? process.cwd());
|
|
43
|
+
const featureDir = join(appRoot, "src", "features", options.name);
|
|
44
|
+
if (existsSync(featureDir)) {
|
|
45
|
+
throw new Error(`scaffoldAppFeature: ${featureDir} already exists — refusing to overwrite`);
|
|
46
|
+
}
|
|
47
|
+
mkdirSync(featureDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const files: string[] = [];
|
|
50
|
+
const featureFile = join(featureDir, "feature.ts");
|
|
51
|
+
writeFileSync(featureFile, renderFeature(options.name));
|
|
52
|
+
files.push(`src/features/${options.name}/feature.ts`);
|
|
53
|
+
|
|
54
|
+
const indexFile = join(featureDir, "index.ts");
|
|
55
|
+
writeFileSync(indexFile, renderIndex(options.name));
|
|
56
|
+
files.push(`src/features/${options.name}/index.ts`);
|
|
57
|
+
|
|
58
|
+
const runConfigPath = join(appRoot, "src", "run-config.ts");
|
|
59
|
+
const autoMounted = existsSync(runConfigPath)
|
|
60
|
+
? mountInRunConfig(runConfigPath, options.name)
|
|
61
|
+
: false;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
featureName: options.name,
|
|
65
|
+
featureDir,
|
|
66
|
+
files,
|
|
67
|
+
autoMounted,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderFeature(name: string): string {
|
|
72
|
+
const camel = kebabToCamel(name);
|
|
73
|
+
return `// ${name} feature — scaffolded by \`kumiko add feature\`. Edit freely.
|
|
74
|
+
//
|
|
75
|
+
// Doc-Pointer: https://docs.kumiko.so/en/patterns/ for the \`r.*\` API
|
|
76
|
+
// (r.entity, r.writeHandler, r.queryHandler, hooks, …).
|
|
77
|
+
|
|
78
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
79
|
+
|
|
80
|
+
export const ${camel}Feature = defineFeature("${name}", (r) => {
|
|
81
|
+
// Starter: declare an entity. Drop and replace with your domain.
|
|
82
|
+
r.entity("${name}-item", {
|
|
83
|
+
fields: {
|
|
84
|
+
title: { type: "text", required: true },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderIndex(name: string): string {
|
|
92
|
+
const camel = kebabToCamel(name);
|
|
93
|
+
return `export { ${camel}Feature } from "./feature";\n`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function kebabToCamel(name: string): string {
|
|
97
|
+
return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 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).
|
|
103
|
+
function mountInRunConfig(runConfigPath: string, name: string): boolean {
|
|
104
|
+
const camel = kebabToCamel(name);
|
|
105
|
+
const project = new Project({
|
|
106
|
+
skipAddingFilesFromTsConfig: true,
|
|
107
|
+
skipFileDependencyResolution: true,
|
|
108
|
+
});
|
|
109
|
+
const sf = project.addSourceFileAtPath(runConfigPath);
|
|
110
|
+
|
|
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 = imports.length > 0 ? imports[imports.length - 1]!.getChildIndex() + 1 : 0;
|
|
118
|
+
sf.insertImportDeclaration(insertIndex, {
|
|
119
|
+
moduleSpecifier: `./features/${name}`,
|
|
120
|
+
namedImports: [`${camel}Feature`],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// 2. Find `export const APP_FEATURES = [...]` and append the new entry.
|
|
124
|
+
const appFeaturesDecl = sf.getVariableDeclaration("APP_FEATURES");
|
|
125
|
+
if (!appFeaturesDecl) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`mountInRunConfig: ${runConfigPath} has no 'APP_FEATURES' declaration — ` +
|
|
128
|
+
`cannot auto-mount. Hand-edit: add '${camel}Feature' to APP_FEATURES.`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const initializer =
|
|
132
|
+
appFeaturesDecl.getInitializerIfKind(SyntaxKind.AsExpression) ??
|
|
133
|
+
appFeaturesDecl.getInitializer();
|
|
134
|
+
if (!initializer) {
|
|
135
|
+
throw new Error(`mountInRunConfig: APP_FEATURES has no initializer — cannot auto-mount.`);
|
|
136
|
+
}
|
|
137
|
+
// Strip `as const` wrapper if present.
|
|
138
|
+
const arr =
|
|
139
|
+
initializer.getKind() === SyntaxKind.AsExpression
|
|
140
|
+
? initializer.getFirstChildByKind(SyntaxKind.ArrayLiteralExpression)
|
|
141
|
+
: initializer.asKind(SyntaxKind.ArrayLiteralExpression);
|
|
142
|
+
if (!arr) {
|
|
143
|
+
throw new Error(`mountInRunConfig: APP_FEATURES is not an array literal — cannot auto-mount.`);
|
|
144
|
+
}
|
|
145
|
+
arr.addElement(`${camel}Feature`);
|
|
146
|
+
|
|
147
|
+
sf.saveSync();
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Re-export so consumers can hint at the file (e.g. for kumiko-cli output).
|
|
152
|
+
export function runConfigPathForApp(appRoot: string): string {
|
|
153
|
+
return join(appRoot, "src", "run-config.ts");
|
|
154
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// scaffoldApp — generate a runnable Kumiko app workspace from a name.
|
|
2
|
+
//
|
|
3
|
+
// Used by `kumiko new app <name>`. Produces the minimal app shape that
|
|
4
|
+
// `KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts` runs successfully against:
|
|
5
|
+
// run-config with 5 foundation features, bin/main.ts with auth-admin
|
|
6
|
+
// stub, package.json with @cosmicdrift/* deps, tsconfig, .env.example,
|
|
7
|
+
// README.
|
|
8
|
+
//
|
|
9
|
+
// Intentionally NOT included in DX-1.0:
|
|
10
|
+
// - drizzle/ setup (DX-1.1 — needs FEATURE_IMPORT_REGISTRY decision from DX-4)
|
|
11
|
+
// - deploy/Dockerfile (already covered by scaffoldDeploy — separate cmd)
|
|
12
|
+
// - first feature scaffold (use scaffoldFeature after this)
|
|
13
|
+
//
|
|
14
|
+
// The generated app is born "boots cleanly, mounts nothing fancy". User
|
|
15
|
+
// runs `kumiko add feature` (DX-2) or hand-edits src/run-config.ts to grow.
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
export type ScaffoldAppOptions = {
|
|
21
|
+
/** kebab-case app name (e.g. "my-shop"). Becomes package-name + folder. */
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** Absolute or cwd-relative target dir. Default: <cwd>/<name>. */
|
|
24
|
+
readonly destination?: string;
|
|
25
|
+
/** npm-version-pin for @cosmicdrift/* deps. Default "*" for latest. */
|
|
26
|
+
readonly frameworkVersion?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ScaffoldAppResult = {
|
|
30
|
+
readonly destination: string;
|
|
31
|
+
readonly files: readonly string[];
|
|
32
|
+
readonly appName: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
|
|
36
|
+
|
|
37
|
+
export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
|
|
38
|
+
if (!KEBAB_RE.test(options.name)) {
|
|
39
|
+
throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
|
|
40
|
+
}
|
|
41
|
+
const cwd = process.cwd();
|
|
42
|
+
const destination = resolve(cwd, options.destination ?? options.name);
|
|
43
|
+
if (existsSync(destination)) {
|
|
44
|
+
throw new Error(`scaffoldApp: ${destination} already exists — refusing to overwrite`);
|
|
45
|
+
}
|
|
46
|
+
const version = options.frameworkVersion ?? "*";
|
|
47
|
+
|
|
48
|
+
mkdirSync(join(destination, "bin"), { recursive: true });
|
|
49
|
+
mkdirSync(join(destination, "src"), { recursive: true });
|
|
50
|
+
|
|
51
|
+
const files: string[] = [];
|
|
52
|
+
|
|
53
|
+
write(join(destination, "package.json"), renderPackageJson(options.name, version));
|
|
54
|
+
files.push("package.json");
|
|
55
|
+
|
|
56
|
+
write(join(destination, "tsconfig.json"), renderTsconfig());
|
|
57
|
+
files.push("tsconfig.json");
|
|
58
|
+
|
|
59
|
+
write(join(destination, "src", "run-config.ts"), renderRunConfig());
|
|
60
|
+
files.push("src/run-config.ts");
|
|
61
|
+
|
|
62
|
+
write(join(destination, "bin", "main.ts"), renderMain(options.name));
|
|
63
|
+
files.push("bin/main.ts");
|
|
64
|
+
|
|
65
|
+
write(join(destination, ".env.example"), renderEnvExample());
|
|
66
|
+
files.push(".env.example");
|
|
67
|
+
|
|
68
|
+
write(join(destination, "README.md"), renderReadme(options.name));
|
|
69
|
+
files.push("README.md");
|
|
70
|
+
|
|
71
|
+
return { destination, files, appName: options.name };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function write(path: string, content: string): void {
|
|
75
|
+
writeFileSync(path, content);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function renderPackageJson(name: string, version: string): string {
|
|
79
|
+
return `${JSON.stringify(
|
|
80
|
+
{
|
|
81
|
+
name,
|
|
82
|
+
version: "0.1.0",
|
|
83
|
+
private: true,
|
|
84
|
+
type: "module",
|
|
85
|
+
scripts: {
|
|
86
|
+
boot: "KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts",
|
|
87
|
+
check: "tsc --noEmit",
|
|
88
|
+
},
|
|
89
|
+
dependencies: {
|
|
90
|
+
"@cosmicdrift/kumiko-bundled-features": version,
|
|
91
|
+
"@cosmicdrift/kumiko-dev-server": version,
|
|
92
|
+
"@cosmicdrift/kumiko-framework": version,
|
|
93
|
+
zod: "^4.4.3",
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
null,
|
|
97
|
+
2,
|
|
98
|
+
)}\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderTsconfig(): string {
|
|
102
|
+
return `${JSON.stringify(
|
|
103
|
+
{
|
|
104
|
+
compilerOptions: {
|
|
105
|
+
strict: true,
|
|
106
|
+
noUncheckedIndexedAccess: true,
|
|
107
|
+
forceConsistentCasingInFileNames: true,
|
|
108
|
+
verbatimModuleSyntax: true,
|
|
109
|
+
target: "ESNext",
|
|
110
|
+
module: "ESNext",
|
|
111
|
+
moduleResolution: "bundler",
|
|
112
|
+
esModuleInterop: true,
|
|
113
|
+
skipLibCheck: true,
|
|
114
|
+
lib: ["ESNext"],
|
|
115
|
+
types: ["bun-types"],
|
|
116
|
+
noEmit: true,
|
|
117
|
+
},
|
|
118
|
+
include: ["bin", "src"],
|
|
119
|
+
},
|
|
120
|
+
null,
|
|
121
|
+
2,
|
|
122
|
+
)}\n`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderRunConfig(): string {
|
|
126
|
+
return `// Single source of truth für die Feature-Komposition deiner App.
|
|
127
|
+
// Bundled-Foundation: secrets + sessions. config/user/tenant/auth-email-password
|
|
128
|
+
// werden via composeFeatures(includeBundled:true) automatisch ergänzt
|
|
129
|
+
// wenn runProdApp mit \`auth: {…}\` aufgerufen wird (siehe bin/main.ts).
|
|
130
|
+
//
|
|
131
|
+
// Neue features hinzufügen:
|
|
132
|
+
// - bunx kumiko add feature <name> (DX-2, automatisch)
|
|
133
|
+
// - oder: hand-edit + import unten ergänzen
|
|
134
|
+
|
|
135
|
+
import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
136
|
+
import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
137
|
+
|
|
138
|
+
export const APP_FEATURES = [
|
|
139
|
+
createSecretsFeature(),
|
|
140
|
+
createSessionsFeature(),
|
|
141
|
+
] as const;
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderMain(appName: string): string {
|
|
146
|
+
// Deterministic tenant-UUID derived from appName for the seed-admin
|
|
147
|
+
// membership. Reproducible across boots; tenants table sees the same
|
|
148
|
+
// ID. Format: 8-4-4-4-12 hex chars, version-4 marker at position 14.
|
|
149
|
+
// We hash the name into the digits using a tiny PRNG so two apps
|
|
150
|
+
// get different IDs without bun's crypto dependency.
|
|
151
|
+
const tenantId = deriveTenantId(appName);
|
|
152
|
+
return `// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
|
|
153
|
+
// composeFeatures + validateBoot + createRegistry without DB/Redis-connect
|
|
154
|
+
// (siehe @cosmicdrift/kumiko-dev-server runProdApp). Echter Dev-Boot
|
|
155
|
+
// passiert via \`bunx kumiko dev\` mit Docker-stack — DX-1.0 deckt nur
|
|
156
|
+
// den boot-mode-Pfad ab; \`kumiko dev\` kommt in einer späteren DX-Phase.
|
|
157
|
+
|
|
158
|
+
import { frameworkCoreEnvSchema, runProdApp } from "@cosmicdrift/kumiko-dev-server";
|
|
159
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
160
|
+
import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
|
|
161
|
+
import { APP_FEATURES } from "../src/run-config";
|
|
162
|
+
|
|
163
|
+
const DEFAULT_TENANT_ID = "${tenantId}" as TenantId;
|
|
164
|
+
|
|
165
|
+
const envSchema = composeEnvSchema({
|
|
166
|
+
core: frameworkCoreEnvSchema,
|
|
167
|
+
features: APP_FEATURES,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await runProdApp({
|
|
171
|
+
features: APP_FEATURES,
|
|
172
|
+
envSchema,
|
|
173
|
+
migrations: false,
|
|
174
|
+
auth: {
|
|
175
|
+
admin: {
|
|
176
|
+
email: "admin@${appName}.local",
|
|
177
|
+
password: "change-me-on-first-deploy",
|
|
178
|
+
displayName: "Admin",
|
|
179
|
+
memberships: [
|
|
180
|
+
{
|
|
181
|
+
tenantId: DEFAULT_TENANT_ID,
|
|
182
|
+
tenantKey: "${appName}",
|
|
183
|
+
tenantName: "${appName}",
|
|
184
|
+
roles: ["TenantAdmin"],
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderEnvExample(): string {
|
|
194
|
+
return `# Required env-vars für boot-mode + dev. Production: über Pulumi/k8s-Secrets.
|
|
195
|
+
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/app
|
|
196
|
+
REDIS_URL=redis://127.0.0.1:6379
|
|
197
|
+
|
|
198
|
+
# JWT_SECRET: min 32 chars. Generate with: openssl rand -base64 32
|
|
199
|
+
JWT_SECRET=change-me-min-32-chars-change-me-min-32
|
|
200
|
+
|
|
201
|
+
# KUMIKO_SECRETS_MASTER_KEY_V1: base64-encoded 32 bytes (AES-256 KEK).
|
|
202
|
+
# Generate with: openssl rand -base64 32
|
|
203
|
+
KUMIKO_SECRETS_MASTER_KEY_V1=
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderReadme(appName: string): string {
|
|
208
|
+
return `# ${appName}
|
|
209
|
+
|
|
210
|
+
Scaffolded by \`kumiko new app\`. Boots out-of-the-box with secrets + sessions
|
|
211
|
+
mounted (foundation set). Add features with \`bunx kumiko add feature <name>\`.
|
|
212
|
+
|
|
213
|
+
## First boot
|
|
214
|
+
|
|
215
|
+
\`\`\`sh
|
|
216
|
+
yarn install
|
|
217
|
+
cp .env.example .env
|
|
218
|
+
# edit .env — set JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1
|
|
219
|
+
bun run boot
|
|
220
|
+
\`\`\`
|
|
221
|
+
|
|
222
|
+
Expected: \`[runProdApp] boot validation OK (… features, … registry entries)\` + exit 0.
|
|
223
|
+
|
|
224
|
+
## Adding features
|
|
225
|
+
|
|
226
|
+
\`\`\`sh
|
|
227
|
+
bunx kumiko add feature my-domain
|
|
228
|
+
# → editiert src/run-config.ts automatisch + scaffolded src/features/my-domain/
|
|
229
|
+
\`\`\`
|
|
230
|
+
|
|
231
|
+
## Architecture
|
|
232
|
+
|
|
233
|
+
- \`src/run-config.ts\` — single source of truth: which features your app mounts.
|
|
234
|
+
- \`bin/main.ts\` — production-bootstrap. Reads env, mounts features, starts server.
|
|
235
|
+
|
|
236
|
+
For full docs see https://docs.kumiko.so.
|
|
237
|
+
`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Deterministic tenant-ID from app-name. Format: UUID-v4 with the
|
|
241
|
+
// version-marker at the right spot. NOT cryptographically random —
|
|
242
|
+
// just a stable per-app default the user can change later.
|
|
243
|
+
function deriveTenantId(name: string): string {
|
|
244
|
+
// Tiny xorshift PRNG seeded from the name's char-codes. Same name →
|
|
245
|
+
// same ID. Sufficient for "give every scaffolded app a deterministic
|
|
246
|
+
// default tenant" — production sets its own via the create-tenant
|
|
247
|
+
// flow anyway.
|
|
248
|
+
let state = 2166136261;
|
|
249
|
+
for (const ch of name) {
|
|
250
|
+
state ^= ch.charCodeAt(0);
|
|
251
|
+
state = Math.imul(state, 16777619) >>> 0;
|
|
252
|
+
}
|
|
253
|
+
const hex = (n: number, len: number): string => n.toString(16).padStart(len, "0").slice(0, len);
|
|
254
|
+
const a = hex(state, 8);
|
|
255
|
+
state ^= state << 13;
|
|
256
|
+
state >>>= 0;
|
|
257
|
+
const b = hex(state, 4);
|
|
258
|
+
// version-4 marker at first char of 3rd group:
|
|
259
|
+
state ^= state >>> 17;
|
|
260
|
+
state >>>= 0;
|
|
261
|
+
const c = `4${hex(state, 3)}`;
|
|
262
|
+
// RFC 4122 variant: 10xx (set top two bits of 4th group to 10):
|
|
263
|
+
state ^= state << 5;
|
|
264
|
+
state >>>= 0;
|
|
265
|
+
const d4 = (0x8 | (state & 0x3)).toString(16);
|
|
266
|
+
const d = `${d4}${hex(state >>> 4, 3)}`;
|
|
267
|
+
state = Math.imul(state, 16777619) >>> 0;
|
|
268
|
+
const e = hex(state, 12);
|
|
269
|
+
return `${a}-${b}-${c}-${d}-${e}`;
|
|
270
|
+
}
|
|
@@ -58,6 +58,15 @@ COPY package.json yarn.lock .yarnrc.yml ./
|
|
|
58
58
|
# bin/kumiko-build.ts), which writes .kumiko/define.ts and turns the
|
|
59
59
|
# symlink real before the bundle is built.
|
|
60
60
|
ENV YARN_ENABLE_INLINE_BUILDS=true
|
|
61
|
+
# Skip postinstall scripts for ALL deps in the build stage. Reason:
|
|
62
|
+
# `bun build` bundles JS source only — no native bindings needed at bundle-
|
|
63
|
+
# time. msgpackr-extract is the most common offender (ARM/CI native-build
|
|
64
|
+
# failures), but the rule applies broadly: any native dep loaded at runtime
|
|
65
|
+
# gets re-installed via `bun install --production` in the runtime stage,
|
|
66
|
+
# which uses bun's own postinstall handling. Apps that needed per-package
|
|
67
|
+
# opt-outs via `dependenciesMeta.<pkg>.built=false` in package.json (e.g.
|
|
68
|
+
# studio, enterprise) can remove those entries after adopting this template.
|
|
69
|
+
ENV YARN_ENABLE_SCRIPTS=false
|
|
61
70
|
{{#hasPrivateGhPackages}}
|
|
62
71
|
# Re-export GITHUB_TOKEN as env so yarn-4's `${GITHUB_TOKEN:-…}` expansion
|
|
63
72
|
# in .yarnrc.yml finds it during the install step.
|
|
@@ -89,6 +98,14 @@ COPY --from=build --chown=app:app /app/dist ./dist
|
|
|
89
98
|
COPY --from=build --chown=app:app /app/dist-server/drizzle.config.ts ./drizzle.config.ts
|
|
90
99
|
COPY --from=build --chown=app:app /app/drizzle ./drizzle
|
|
91
100
|
|
|
101
|
+
# Container entrypoint — `infra/pulumi/bun-server.ts` overrides the container
|
|
102
|
+
# command to inject DATABASE_URL from the init-container's /shared/database-url
|
|
103
|
+
# then execs `./start.sh`. We generate it inline so app-source-roots stay
|
|
104
|
+
# clean (no per-app start.sh duplication). Apps that don't go through
|
|
105
|
+
# createBunServer's command-override still boot via the CMD at the bottom
|
|
106
|
+
# (`exec bun run server.js`) — start.sh is dead-code in that case.
|
|
107
|
+
RUN printf '#!/bin/sh\nset -e\nexec bun run server.js\n' > ./start.sh && chmod +x ./start.sh
|
|
108
|
+
|
|
92
109
|
{{#hasSeeds}}
|
|
93
110
|
# ES-Operations seed migrations — runtime-loaded via dynamic import. Bun
|
|
94
111
|
# does NOT bundle these into dist-server/server.js (await import on a
|