@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
|
@@ -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
|