@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.
@@ -0,0 +1,333 @@
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
+ // .ts files are built via ts-morph (same tool [[scaffoldAppFeature]] uses
10
+ // to auto-mount features). Means a single AST representation for both
11
+ // generate + later modify — no template-string ↔ ts-morph divergence.
12
+ // Static files (package.json, tsconfig, .env, README) stay text-based.
13
+
14
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+ import { IndentationText, Project, VariableDeclarationKind } from "ts-morph";
17
+
18
+ export type ScaffoldAppOptions = {
19
+ /** kebab-case app name (e.g. "my-shop"). Becomes package-name + folder. */
20
+ readonly name: string;
21
+ /** Absolute or cwd-relative target dir. Default: <cwd>/<name>. */
22
+ readonly destination?: string;
23
+ /** npm-version-pin for @cosmicdrift/* deps. Default "*" for latest. */
24
+ readonly frameworkVersion?: string;
25
+ };
26
+
27
+ export type ScaffoldAppResult = {
28
+ readonly destination: string;
29
+ readonly files: readonly string[];
30
+ readonly appName: string;
31
+ };
32
+
33
+ const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
34
+
35
+ export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
36
+ if (!KEBAB_RE.test(options.name)) {
37
+ throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
38
+ }
39
+ const cwd = process.cwd();
40
+ const destination = resolve(cwd, options.destination ?? options.name);
41
+ if (existsSync(destination)) {
42
+ throw new Error(`scaffoldApp: ${destination} already exists — refusing to overwrite`);
43
+ }
44
+ const version = options.frameworkVersion ?? "*";
45
+
46
+ mkdirSync(join(destination, "bin"), { recursive: true });
47
+ mkdirSync(join(destination, "src"), { recursive: true });
48
+
49
+ const files: string[] = [];
50
+
51
+ write(join(destination, "package.json"), renderPackageJson(options.name, version));
52
+ files.push("package.json");
53
+
54
+ write(join(destination, "tsconfig.json"), renderTsconfig());
55
+ files.push("tsconfig.json");
56
+
57
+ write(join(destination, "src", "run-config.ts"), renderRunConfig());
58
+ files.push("src/run-config.ts");
59
+
60
+ write(join(destination, "bin", "main.ts"), renderMain(options.name));
61
+ files.push("bin/main.ts");
62
+
63
+ write(join(destination, ".env.example"), renderEnvExample());
64
+ files.push(".env.example");
65
+
66
+ write(join(destination, "README.md"), renderReadme(options.name));
67
+ files.push("README.md");
68
+
69
+ return { destination, files, appName: options.name };
70
+ }
71
+
72
+ function write(path: string, content: string): void {
73
+ writeFileSync(path, content);
74
+ }
75
+
76
+ function renderPackageJson(name: string, version: string): string {
77
+ return `${JSON.stringify(
78
+ {
79
+ name,
80
+ version: "0.1.0",
81
+ private: true,
82
+ type: "module",
83
+ scripts: {
84
+ boot: "KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts",
85
+ check: "tsc --noEmit",
86
+ },
87
+ dependencies: {
88
+ "@cosmicdrift/kumiko-bundled-features": version,
89
+ "@cosmicdrift/kumiko-dev-server": version,
90
+ "@cosmicdrift/kumiko-framework": version,
91
+ zod: "^4.4.3",
92
+ },
93
+ },
94
+ null,
95
+ 2,
96
+ )}\n`;
97
+ }
98
+
99
+ function renderTsconfig(): string {
100
+ return `${JSON.stringify(
101
+ {
102
+ compilerOptions: {
103
+ strict: true,
104
+ noUncheckedIndexedAccess: true,
105
+ forceConsistentCasingInFileNames: true,
106
+ verbatimModuleSyntax: true,
107
+ target: "ESNext",
108
+ module: "ESNext",
109
+ moduleResolution: "bundler",
110
+ esModuleInterop: true,
111
+ skipLibCheck: true,
112
+ lib: ["ESNext"],
113
+ types: ["bun-types"],
114
+ noEmit: true,
115
+ },
116
+ include: ["bin", "src"],
117
+ },
118
+ null,
119
+ 2,
120
+ )}\n`;
121
+ }
122
+
123
+ function newTsProject(): Project {
124
+ return new Project({
125
+ useInMemoryFileSystem: true,
126
+ compilerOptions: { target: 99, module: 99, strict: true },
127
+ manipulationSettings: { indentationText: IndentationText.TwoSpaces },
128
+ });
129
+ }
130
+
131
+ function renderRunConfig(): string {
132
+ const project = newTsProject();
133
+ const sf = project.createSourceFile("run-config.ts", "");
134
+
135
+ sf.addImportDeclaration({
136
+ moduleSpecifier: "@cosmicdrift/kumiko-bundled-features/secrets",
137
+ namedImports: ["createSecretsFeature"],
138
+ });
139
+ sf.addImportDeclaration({
140
+ moduleSpecifier: "@cosmicdrift/kumiko-bundled-features/sessions",
141
+ namedImports: ["createSessionsFeature"],
142
+ });
143
+
144
+ sf.addVariableStatement({
145
+ declarationKind: VariableDeclarationKind.Const,
146
+ isExported: true,
147
+ declarations: [
148
+ {
149
+ name: "APP_FEATURES",
150
+ initializer: "[createSecretsFeature(), createSessionsFeature()] as const",
151
+ },
152
+ ],
153
+ });
154
+
155
+ sf.insertText(
156
+ 0,
157
+ [
158
+ "// Single source of truth für die Feature-Komposition deiner App.",
159
+ "// Bundled-Foundation: secrets + sessions. config/user/tenant/auth-email-password",
160
+ "// werden via composeFeatures(includeBundled:true) automatisch ergänzt",
161
+ "// wenn runProdApp mit `auth: {…}` aufgerufen wird (siehe bin/main.ts).",
162
+ "//",
163
+ "// Neue features hinzufügen:",
164
+ "// - bunx @cosmicdrift/kumiko-cli add feature <name> (DX-2, automatisch)",
165
+ "// - oder: hand-edit + import unten ergänzen",
166
+ "",
167
+ "",
168
+ ].join("\n"),
169
+ );
170
+
171
+ return sf.getFullText();
172
+ }
173
+
174
+ function renderMain(appName: string): string {
175
+ const tenantId = deriveTenantId(appName);
176
+ const project = newTsProject();
177
+ const sf = project.createSourceFile("main.ts", "");
178
+
179
+ sf.addImportDeclaration({
180
+ moduleSpecifier: "@cosmicdrift/kumiko-dev-server",
181
+ namedImports: ["frameworkCoreEnvSchema", "runProdApp"],
182
+ });
183
+ sf.addImportDeclaration({
184
+ moduleSpecifier: "@cosmicdrift/kumiko-framework/engine",
185
+ isTypeOnly: true,
186
+ namedImports: ["TenantId"],
187
+ });
188
+ sf.addImportDeclaration({
189
+ moduleSpecifier: "@cosmicdrift/kumiko-framework/env",
190
+ namedImports: ["composeEnvSchema"],
191
+ });
192
+ sf.addImportDeclaration({
193
+ moduleSpecifier: "../src/run-config",
194
+ namedImports: ["APP_FEATURES"],
195
+ });
196
+
197
+ sf.addVariableStatement({
198
+ declarationKind: VariableDeclarationKind.Const,
199
+ declarations: [
200
+ {
201
+ name: "DEFAULT_TENANT_ID",
202
+ initializer: `"${tenantId}" as TenantId`,
203
+ },
204
+ ],
205
+ });
206
+
207
+ sf.addVariableStatement({
208
+ declarationKind: VariableDeclarationKind.Const,
209
+ declarations: [
210
+ {
211
+ name: "envSchema",
212
+ initializer: "composeEnvSchema({ core: frameworkCoreEnvSchema, features: APP_FEATURES })",
213
+ },
214
+ ],
215
+ });
216
+
217
+ sf.addStatements((writer) => {
218
+ writer
219
+ .write("await runProdApp(")
220
+ .inlineBlock(() => {
221
+ writer.writeLine("features: APP_FEATURES,");
222
+ writer.writeLine("envSchema,");
223
+ writer.writeLine("migrations: false,");
224
+ writer.write("auth: ").inlineBlock(() => {
225
+ writer.write("admin: ").inlineBlock(() => {
226
+ writer.writeLine(`email: "admin@${appName}.local",`);
227
+ writer.writeLine(`password: "change-me-on-first-deploy",`);
228
+ writer.writeLine(`displayName: "Admin",`);
229
+ writer.write("memberships: [");
230
+ writer.indent(() => {
231
+ writer.inlineBlock(() => {
232
+ writer.writeLine("tenantId: DEFAULT_TENANT_ID,");
233
+ writer.writeLine(`tenantKey: "${appName}",`);
234
+ writer.writeLine(`tenantName: "${appName}",`);
235
+ writer.writeLine(`roles: ["TenantAdmin"],`);
236
+ });
237
+ writer.write(",");
238
+ });
239
+ writer.write("],");
240
+ });
241
+ });
242
+ })
243
+ .write(");");
244
+ });
245
+
246
+ sf.insertText(
247
+ 0,
248
+ [
249
+ "// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after",
250
+ "// composeFeatures + validateBoot + createRegistry without DB/Redis-connect",
251
+ "// (siehe @cosmicdrift/kumiko-dev-server runProdApp). Echter Dev-Boot",
252
+ "// passiert via `yarn kumiko dev` (in-repo dev-tool) mit Docker-stack — DX-1.0 deckt nur",
253
+ "// den boot-mode-Pfad ab; `kumiko dev` kommt in einer späteren DX-Phase.",
254
+ "",
255
+ "",
256
+ ].join("\n"),
257
+ );
258
+
259
+ return sf.getFullText();
260
+ }
261
+
262
+ function renderEnvExample(): string {
263
+ return `# Required env-vars für boot-mode + dev. Production: über Pulumi/k8s-Secrets.
264
+ DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/app
265
+ REDIS_URL=redis://127.0.0.1:6379
266
+
267
+ # JWT_SECRET: min 32 chars. Generate with: openssl rand -base64 32
268
+ JWT_SECRET=change-me-min-32-chars-change-me-min-32
269
+
270
+ # KUMIKO_SECRETS_MASTER_KEY_V1: base64-encoded 32 bytes (AES-256 KEK).
271
+ # Generate with: openssl rand -base64 32
272
+ KUMIKO_SECRETS_MASTER_KEY_V1=
273
+ `;
274
+ }
275
+
276
+ function renderReadme(appName: string): string {
277
+ return `# ${appName}
278
+
279
+ Scaffolded by \`kumiko new app\`. Boots out-of-the-box with secrets + sessions
280
+ mounted (foundation set). Add features with \`bunx @cosmicdrift/kumiko-cli add feature <name>\`.
281
+
282
+ ## First boot
283
+
284
+ \`\`\`sh
285
+ yarn install
286
+ cp .env.example .env
287
+ # edit .env — set JWT_SECRET + KUMIKO_SECRETS_MASTER_KEY_V1
288
+ bun run boot
289
+ \`\`\`
290
+
291
+ Expected: \`[runProdApp] boot validation OK (… features, … registry entries)\` + exit 0.
292
+
293
+ ## Adding features
294
+
295
+ \`\`\`sh
296
+ bunx @cosmicdrift/kumiko-cli add feature my-domain
297
+ # → editiert src/run-config.ts automatisch + scaffolded src/features/my-domain/
298
+ \`\`\`
299
+
300
+ ## Architecture
301
+
302
+ - \`src/run-config.ts\` — single source of truth: which features your app mounts.
303
+ - \`bin/main.ts\` — production-bootstrap. Reads env, mounts features, starts server.
304
+
305
+ For full docs see https://docs.kumiko.so.
306
+ `;
307
+ }
308
+
309
+ // Deterministic tenant-ID from app-name. Format: UUID-v4 with the
310
+ // version-marker at the right spot. NOT cryptographically random —
311
+ // just a stable per-app default the user can change later.
312
+ function deriveTenantId(name: string): string {
313
+ let state = 2166136261;
314
+ for (const ch of name) {
315
+ state ^= ch.charCodeAt(0);
316
+ state = Math.imul(state, 16777619) >>> 0;
317
+ }
318
+ const hex = (n: number, len: number): string => n.toString(16).padStart(len, "0").slice(0, len);
319
+ const a = hex(state, 8);
320
+ state ^= state << 13;
321
+ state >>>= 0;
322
+ const b = hex(state, 4);
323
+ state ^= state >>> 17;
324
+ state >>>= 0;
325
+ const c = `4${hex(state, 3)}`;
326
+ state ^= state << 5;
327
+ state >>>= 0;
328
+ const d4 = (0x8 | (state & 0x3)).toString(16);
329
+ const d = `${d4}${hex(state >>> 4, 3)}`;
330
+ state = Math.imul(state, 16777619) >>> 0;
331
+ const e = hex(state, 12);
332
+ return `${a}-${b}-${c}-${d}-${e}`;
333
+ }
@@ -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