@cosmicdrift/kumiko-dev-server 0.364.0 → 0.365.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.
@@ -110,9 +110,9 @@ export async function buildServerBundle(options = {}) {
110
110
  await mkdir(outDir, { recursive: true });
111
111
  // Ein Bun.build-Call mit allen Entries + splitting → das Framework wird
112
112
  // einmal als shared chunk abgelegt statt pro Entry inlined.
113
- const optionalSiblingEntries = ["worker.ts", "bootstrap.ts"]
114
- .map((file) => join(dirname(serverEntry), file))
115
- .filter((path) => existsSync(path));
113
+ const optionalSiblingEntries = ["worker", "bootstrap"]
114
+ .map((baseName) => discoverSiblingEntry(dirname(serverEntry), baseName))
115
+ .filter((path) => path !== undefined);
116
116
  const entrypoints = [serverEntry, ...(kumikoCli ? [kumikoCli] : []), ...optionalSiblingEntries];
117
117
  const result = await Bun.build({
118
118
  entrypoints,
@@ -162,12 +162,15 @@ export async function buildServerBundle(options = {}) {
162
162
  return { outDir, entries, chunks, totalBytes, runtimeDeps };
163
163
  }
164
164
  export function discoverServerEntry(cwd) {
165
- const tsEntry = join(cwd, "bin/main.ts");
166
- if (existsSync(tsEntry))
167
- return tsEntry;
168
- const jsEntry = join(cwd, "bin/main.js");
169
- if (existsSync(jsEntry))
170
- return jsEntry;
165
+ return discoverSiblingEntry(join(cwd, "bin"), "main");
166
+ }
167
+ // `.ts` wins over `.js`, same precedence for the server entry and its siblings.
168
+ function discoverSiblingEntry(dir, baseName) {
169
+ for (const extension of [".ts", ".js"]) {
170
+ const candidate = join(dir, `${baseName}${extension}`);
171
+ if (existsSync(candidate))
172
+ return candidate;
173
+ }
171
174
  return undefined;
172
175
  }
173
176
  // Walking up vom App-cwd bis ein Verzeichnis mit `bin/kumiko.ts` gefunden wird.
package/dist/cli.d.ts CHANGED
@@ -2,4 +2,3 @@ export { buildProdBundle, buildServerBundle, discoverServerEntry, formatBuildRes
2
2
  export { formatScanWarning, runCodegen } from "./codegen/index.js";
3
3
  export { createCrashTracker } from "./crash-tracker.js";
4
4
  export { runInitDeployCli } from "./init-deploy-cli.js";
5
- export { implicitAuthModeFeatureNames, resolveGeneratePath } from "./schema-check-core.js";
package/dist/cli.js CHANGED
@@ -6,4 +6,3 @@ export { buildProdBundle, buildServerBundle, discoverServerEntry, formatBuildRes
6
6
  export { formatScanWarning, runCodegen } from "./codegen/index.js";
7
7
  export { createCrashTracker } from "./crash-tracker.js";
8
8
  export { runInitDeployCli } from "./init-deploy-cli.js";
9
- export { implicitAuthModeFeatureNames, resolveGeneratePath } from "./schema-check-core.js";
@@ -2,6 +2,7 @@ import { type AuthMfaFeatureOptions } from "@cosmicdrift/kumiko-bundled-features
2
2
  import { type LegalPagesOptions } from "@cosmicdrift/kumiko-bundled-features/legal-pages";
3
3
  import { type UserDataRightsOptions } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
4
4
  import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { type MailBrandSuffixResolver, type MailFooterResolver } from "@cosmicdrift/kumiko-renderer-mail-html";
5
6
  export type FileProviderKind = "inmemory" | "s3" | "s3-env";
6
7
  export type MailTransportKind = "inmemory" | "smtp";
7
8
  export type GdprStackOrder = "retention-first" | "compliance-first";
@@ -48,7 +49,13 @@ export type IdentityStackOptions = {
48
49
  };
49
50
  export declare function stackFeatureNames(features: readonly FeatureDefinition[]): string[];
50
51
  export declare function composePagesStack(options?: PagesStackOptions): FeatureDefinition[];
51
- export declare function composeRendererStack(): FeatureDefinition[];
52
+ export type RendererStackOptions = {
53
+ /** Platform imprint, privacy link and footer text under every mail; see createRendererMailHtmlFeature. */
54
+ readonly mailFooter?: MailFooterResolver;
55
+ /** Word after the tenant name in the mail's brand row, e.g. "Status". */
56
+ readonly mailBrandSuffix?: MailBrandSuffixResolver;
57
+ };
58
+ export declare function composeRendererStack(options?: RendererStackOptions): FeatureDefinition[];
52
59
  export declare function composeMailStack(options: MailStackOptions): FeatureDefinition[];
53
60
  export declare function composeFileStack(options: FileStackOptions): FeatureDefinition[];
54
61
  /** Retention + compliance (+ optional lifecycle/sessions). UDR/files mount separately — order varies per app. */
@@ -30,7 +30,7 @@ import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-featu
30
30
  import { createTenantLifecycleFeature } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
31
31
  import { createUserDataRightsFeature, } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
32
32
  import { createUserDataRightsDefaultsFeature } from "@cosmicdrift/kumiko-bundled-features/user-data-rights-defaults";
33
- import { createRendererMailHtmlFeature } from "@cosmicdrift/kumiko-renderer-mail-html";
33
+ import { createRendererMailHtmlFeature, } from "@cosmicdrift/kumiko-renderer-mail-html";
34
34
  export function stackFeatureNames(features) {
35
35
  return features.map((f) => f.name);
36
36
  }
@@ -44,11 +44,14 @@ export function composePagesStack(options = {}) {
44
44
  createLegalPagesFeature(options.wrapLayout !== undefined ? { wrapLayout: options.wrapLayout } : {}),
45
45
  ];
46
46
  }
47
- export function composeRendererStack() {
47
+ export function composeRendererStack(options = {}) {
48
48
  return [
49
49
  createTemplateResolverFeature(),
50
50
  createRendererFoundationFeature(),
51
- createRendererMailHtmlFeature(),
51
+ createRendererMailHtmlFeature({
52
+ ...(options.mailFooter !== undefined && { footer: options.mailFooter }),
53
+ ...(options.mailBrandSuffix !== undefined && { brandSuffix: options.mailBrandSuffix }),
54
+ }),
52
55
  ];
53
56
  }
54
57
  export function composeMailStack(options) {
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@ export { runInitDeployCli } from "./init-deploy-cli.js";
11
11
  export type { RunDevAppAuthOptions, RunDevAppOptions, SeedFn } from "./run-dev-app.js";
12
12
  export { runDevApp } from "./run-dev-app.js";
13
13
  export type { ScaffoldAppOptions, ScaffoldAppResult, ScaffoldFeatureEntry, } from "./scaffold-app.js";
14
- export { scaffoldApp } from "./scaffold-app.js";
14
+ export { SCAFFOLD_BIOME_VERSION, scaffoldApp } from "./scaffold-app.js";
15
15
  export type { ScaffoldAppFeatureOptions, ScaffoldAppFeatureResult, } from "./scaffold-app-feature.js";
16
16
  export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature.js";
17
17
  export type { CheckDeployDriftResult, DeployDriftEntry, RenderDeployFilesOptions, RenderDeployFilesResult, RenderedDeployFile, ScaffoldDeployDetected, ScaffoldDeployOptions, ScaffoldDeployResult, ScaffoldedFile, } from "./scaffold-deploy.js";
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@ export { buildFewShotCorpus, pathToId } from "./few-shot-corpus.js";
25
25
  export { resolveFrameworkVersion } from "./framework-version.js";
26
26
  export { runInitDeployCli } from "./init-deploy-cli.js";
27
27
  export { runDevApp } from "./run-dev-app.js";
28
- export { scaffoldApp } from "./scaffold-app.js";
28
+ export { SCAFFOLD_BIOME_VERSION, scaffoldApp } from "./scaffold-app.js";
29
29
  export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature.js";
30
30
  export { checkDeployDrift, renderDeployFiles, scaffoldDeploy } from "./scaffold-deploy.js";
31
31
  export { scaffoldFeature } from "./scaffold-feature.js";
@@ -2,6 +2,8 @@ import { type KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
2
2
  export type DevPiiKmsResolution = {
3
3
  readonly kms: KmsAdapter | undefined;
4
4
  readonly blindIndexKey: string | undefined;
5
+ /** True only when the ephemeral default minted the key, not when the app passed one. */
6
+ readonly generatedBlindIndexKey: boolean;
5
7
  readonly mode: "app-kms" | "ephemeral-default" | "plaintext";
6
8
  };
7
9
  type DevPiiKmsOptions = {
@@ -8,7 +8,9 @@ export function captureEphemeralDevPiiKms(resolution) {
8
8
  if (resolution.mode !== "ephemeral-default")
9
9
  return () => { };
10
10
  const ownKms = configuredPiiSubjectKms();
11
- const ownBlindIndexKey = configuredBlindIndexKey();
11
+ const ownBlindIndexKey = resolution.generatedBlindIndexKey
12
+ ? configuredBlindIndexKey()
13
+ : undefined;
12
14
  return () => {
13
15
  if (ownKms !== undefined && configuredPiiSubjectKms() === ownKms) {
14
16
  configurePiiSubjectKms(undefined);
@@ -20,14 +22,25 @@ export function captureEphemeralDevPiiKms(resolution) {
20
22
  }
21
23
  export function resolveDevPiiKms(options, env = process.env) {
22
24
  if (options.kms !== undefined && options.kms !== false) {
23
- return { kms: options.kms, blindIndexKey: options.blindIndexKey, mode: "app-kms" };
25
+ return {
26
+ kms: options.kms,
27
+ blindIndexKey: options.blindIndexKey,
28
+ generatedBlindIndexKey: false,
29
+ mode: "app-kms",
30
+ };
24
31
  }
25
32
  if (options.kms === false || isPersistentDevDb(env)) {
26
- return { kms: undefined, blindIndexKey: options.blindIndexKey, mode: "plaintext" };
33
+ return {
34
+ kms: undefined,
35
+ blindIndexKey: options.blindIndexKey,
36
+ generatedBlindIndexKey: false,
37
+ mode: "plaintext",
38
+ };
27
39
  }
28
40
  return {
29
41
  kms: new InMemoryKmsAdapter(),
30
42
  blindIndexKey: options.blindIndexKey ?? randomBytes(32).toString("base64"),
43
+ generatedBlindIndexKey: options.blindIndexKey === undefined,
31
44
  mode: "ephemeral-default",
32
45
  };
33
46
  }
@@ -22,7 +22,7 @@ import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
22
22
  import { createInMemoryLoginRateLimiter, parseTrustedProxyHopsEnv, TRUSTED_PROXY_HOPS_ENV, } from "@cosmicdrift/kumiko-framework/api";
23
23
  import { configureBlindIndexKey, configurePiiSubjectKms, } from "@cosmicdrift/kumiko-framework/crypto";
24
24
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
25
- import { collectWriteHandlerQns, createRegistry, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, findTierResolverUsage, isTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { collectWriteHandlerQns, createRegistry, FILE_STORAGE_PROVIDER_BOOT_SENTINEL, FILE_STORAGE_PROVIDER_ENV, findTierResolverUsage, requireTierResolverPlugin, validateAppCustomScreenWriteQns, validateBoot, } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
27
27
  import { applyBootSeeds } from "@cosmicdrift/kumiko-server-runtime/boot/apply-boot-seeds";
28
28
  import { resolveBootCrypto } from "@cosmicdrift/kumiko-server-runtime/boot/boot-crypto";
@@ -202,6 +202,13 @@ export async function runDevApp(options) {
202
202
  let tokenVerifier;
203
203
  const patFeature = features.find((f) => f.name === PAT_FEATURE);
204
204
  const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
205
+ if (effectiveAuth &&
206
+ mfaFeature &&
207
+ registry?.getWriteHandler(AuthHandlers.switchTenantMfaGate) === undefined) {
208
+ throw new Error(`[runDevApp] auth-mfa is mounted but ${AuthHandlers.switchTenantMfaGate} is not registered: ` +
209
+ "createAuthEmailPasswordFeature registers it only when mfaStatusChecker is set, and without it " +
210
+ "POST /auth/switch-tenant would mint sessions without the second factor");
211
+ }
205
212
  const patAuthFragment = patFeature
206
213
  ? {
207
214
  tokenVerifier: (rawToken) => {
@@ -327,11 +334,7 @@ export async function runDevApp(options) {
327
334
  // on the resolver being live (e.g. seed that writes a SystemAdmin's
328
335
  // tier-assignment can immediately read tier-cuts).
329
336
  if (tierResolverUsage) {
330
- const plugin = tierResolverUsage.options;
331
- if (!isTierResolverPlugin(plugin)) {
332
- throw new Error("tier-resolver registration options must provide a build() factory");
333
- }
334
- tierResolverHolder.resolver = await plugin.build({
337
+ tierResolverHolder.resolver = await requireTierResolverPlugin(tierResolverUsage).build({
335
338
  db: stack.db,
336
339
  registry: stack.registry,
337
340
  cacheSync: stack.cacheSync,
@@ -8,8 +8,7 @@
8
8
  // Auto-mount via ts-morph: opens src/run-config.ts, finds
9
9
  // `export const APP_FEATURES = [...]`, prepends import + appends entry.
10
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.
11
+ // for the run-config side; the schema is derived from the mounted features.
13
12
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
14
13
  import { join, resolve } from "node:path";
15
14
  import { Project, SyntaxKind } from "ts-morph";
@@ -16,6 +16,7 @@ export type ScaffoldTestSetupFactory = (input: {
16
16
  readonly appName: string;
17
17
  readonly frameworkVersion: string;
18
18
  }) => ScaffoldTestSetup;
19
+ export declare const SCAFFOLD_BIOME_VERSION = "2.5.7";
19
20
  export type ScaffoldAppOptions = {
20
21
  /** kebab-case app name (e.g. "my-shop"). Becomes package-name + folder. */
21
22
  readonly name: string;
@@ -6,19 +6,28 @@
6
6
  // stub, package.json with @cosmicdrift/* deps, tsconfig, .env.example,
7
7
  // README.
8
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.
9
+ // Generated files are emitted already formatted to Biome's output (import
10
+ // order, 100-col wrapping, trailing commas) so a fresh app passes `biome check`.
12
11
  // Static files (package.json, tsconfig, .env, README) stay text-based.
13
12
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
14
13
  import { dirname, join, relative, resolve } from "node:path";
15
14
  import { collectTableMetas, generateMigration, writeSnapshotJson, } from "@cosmicdrift/kumiko-framework/db";
16
15
  import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
17
- import { IndentationText, Project, VariableDeclarationKind } from "ts-morph";
18
16
  import { isKebabSegment } from "./kebab.js";
19
17
  import { renderAgentsMd, renderClaudeMd } from "./scaffold-agent-docs.js";
20
18
  import { createDemoTasksFeature, renderDemoSeedFile, renderDemoTasksFeatureFile, renderDemoTasksI18n, renderDemoTasksIndex, renderDemoTasksWebIndex, } from "./scaffold-demo-tasks.js";
21
19
  import { scaffoldDeploy } from "./scaffold-deploy.js";
20
+ // Exact pin (no caret): the generated files are formatted to this Biome's output,
21
+ // and a scaffold-lint test keeps it equal to the monorepo's own Biome version.
22
+ export const SCAFFOLD_BIOME_VERSION = "2.5.7";
23
+ const SCAFFOLD_GITIGNORE = `node_modules/
24
+ dist/
25
+ dist-server/
26
+ .env
27
+ .kumiko/
28
+ test-results/
29
+ playwright-report/
30
+ `;
22
31
  export async function scaffoldApp(options) {
23
32
  if (!isKebabSegment(options.name)) {
24
33
  throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
@@ -42,6 +51,8 @@ export async function scaffoldApp(options) {
42
51
  files.push("kumiko.json");
43
52
  write(join(destination, "biome.json"), renderBiomeJson());
44
53
  files.push("biome.json");
54
+ write(join(destination, ".gitignore"), SCAFFOLD_GITIGNORE);
55
+ files.push(".gitignore");
45
56
  if (testSetup === undefined) {
46
57
  write(join(destination, "bunfig.toml"), renderBunfigToml());
47
58
  files.push("bunfig.toml");
@@ -132,7 +143,7 @@ function renderPackageJson(name, version, testSetup) {
132
143
  zod: "^4.4.3",
133
144
  },
134
145
  devDependencies: sortedByName({
135
- "@biomejs/biome": "^2.4.15",
146
+ "@biomejs/biome": SCAFFOLD_BIOME_VERSION,
136
147
  "@tailwindcss/cli": "^4.3.0",
137
148
  "@types/react": "^19.2.0",
138
149
  "@types/react-dom": "^19.2.0",
@@ -168,7 +179,7 @@ function renderTsconfig() {
168
179
  }
169
180
  function renderBiomeJson() {
170
181
  return `${JSON.stringify({
171
- $schema: "https://biomejs.dev/schemas/2.4.15/schema.json",
182
+ $schema: `https://biomejs.dev/schemas/${SCAFFOLD_BIOME_VERSION}/schema.json`,
172
183
  vcs: {
173
184
  enabled: true,
174
185
  clientKind: "git",
@@ -209,7 +220,7 @@ function renderBiomeJson() {
209
220
  linter: {
210
221
  enabled: true,
211
222
  rules: {
212
- recommended: true,
223
+ preset: "recommended",
213
224
  correctness: { noUnusedVariables: "error", noUnusedImports: "error" },
214
225
  suspicious: { noExplicitAny: "error", noDebugger: "error", noConsole: "warn" },
215
226
  complexity: { useLiteralKeys: "off" },
@@ -254,13 +265,6 @@ pathIgnorePatterns = [
254
265
  ]
255
266
  `;
256
267
  }
257
- function newTsProject() {
258
- return new Project({
259
- useInMemoryFileSystem: true,
260
- compilerOptions: { target: 99, module: 99, strict: true },
261
- manipulationSettings: { indentationText: IndentationText.TwoSpaces },
262
- });
263
- }
264
268
  const FOUNDATION_FEATURES = [
265
269
  {
266
270
  name: "secrets",
@@ -289,9 +293,40 @@ const FOUNDATION_FEATURES = [
289
293
  // jemand scaffoldApp() direkt mit allen Bundled-Feature-Entries aufruft
290
294
  // rutschen die 4 nicht in run-config.ts.
291
295
  const COMPOSE_AUTO_MOUNTED_NAMES = new Set(["config", "user", "tenant", "auth-email-password"]);
296
+ const MAX_LINE_WIDTH = 100;
297
+ // Mirrors Biome's import sort so generated files pass `biome check` untouched:
298
+ // packages alphabetically by specifier, relative paths last.
299
+ function compareImportKeys(a, b) {
300
+ const lowerA = a.replace(/^type /, "").toLowerCase();
301
+ const lowerB = b.replace(/^type /, "").toLowerCase();
302
+ if (lowerA !== lowerB)
303
+ return lowerA < lowerB ? -1 : 1;
304
+ return a < b ? -1 : a > b ? 1 : 0;
305
+ }
306
+ function isRelativeSpecifier(specifier) {
307
+ return specifier.startsWith("./") || specifier.startsWith("../");
308
+ }
309
+ function renderImportLine({ specifier, names, typeOnly }) {
310
+ const sorted = [...names].sort(compareImportKeys);
311
+ const keyword = typeOnly === true ? "import type" : "import";
312
+ const oneLine = `${keyword} { ${sorted.join(", ")} } from "${specifier}";`;
313
+ if (sorted.length === 1 || oneLine.length <= MAX_LINE_WIDTH)
314
+ return oneLine;
315
+ return `${keyword} {\n${sorted.map((n) => ` ${n},`).join("\n")}\n} from "${specifier}";`;
316
+ }
317
+ function renderSortedImports(imports) {
318
+ return [...imports]
319
+ .sort((a, b) => {
320
+ const relativeA = isRelativeSpecifier(a.specifier);
321
+ const relativeB = isRelativeSpecifier(b.specifier);
322
+ if (relativeA !== relativeB)
323
+ return relativeA ? 1 : -1;
324
+ return compareImportKeys(a.specifier, b.specifier);
325
+ })
326
+ .map(renderImportLine)
327
+ .join("\n");
328
+ }
292
329
  function renderRunConfig(features) {
293
- const project = newTsProject();
294
- const sf = project.createSourceFile("run-config.ts", "");
295
330
  // Fallback decided BEFORE filtering: if the caller passed features (even
296
331
  // if every one of them is an auto-mounted name), an all-filtered-out empty
297
332
  // result is the correct outcome — falling back to FOUNDATION_FEATURES here
@@ -306,289 +341,215 @@ function renderRunConfig(features) {
306
341
  existing.push(entry.exportName);
307
342
  grouped.set(entry.importPath, existing);
308
343
  }
309
- for (const [importPath, namedImports] of grouped) {
310
- sf.addImportDeclaration({ moduleSpecifier: importPath, namedImports });
311
- }
312
- sf.addImportDeclaration({
313
- moduleSpecifier: "./features/tasks",
314
- namedImports: ["tasksFeature"],
315
- });
344
+ const imports = [
345
+ ...[...grouped].map(([specifier, names]) => ({ specifier, names })),
346
+ { specifier: "./features/tasks", names: ["tasksFeature"] },
347
+ ];
316
348
  const callExprs = [...effective.map((f) => f.callExpression), "tasksFeature"];
317
- sf.addVariableStatement({
318
- declarationKind: VariableDeclarationKind.Const,
319
- isExported: true,
320
- declarations: [
321
- {
322
- name: "APP_FEATURES",
323
- initializer: `[${callExprs.join(", ")}] as const`,
324
- },
325
- ],
326
- });
327
- sf.addVariableStatement({
328
- declarationKind: VariableDeclarationKind.Const,
329
- isExported: true,
330
- declarations: [{ name: "HAS_AUTH", initializer: "true" }],
331
- });
332
- sf.insertText(0, [
333
- "// Single source of truth for your app's feature composition.",
334
- "// config/user/tenant/auth-email-password are added automatically",
335
- "// via composeFeatures(includeBundled:true) when runProdApp is called",
336
- "// with `auth: {…}` (see bin/main.ts).",
337
- "//",
338
- "// Add new features:",
339
- "// - bunx @cosmicdrift/kumiko-cli add feature <name> (DX-2, automatic)",
340
- "// - or: hand-edit + add the import below",
341
- "",
342
- "",
343
- ].join("\n"));
344
- return sf.getFullText();
349
+ const oneLineFeatures = `export const APP_FEATURES = [${callExprs.join(", ")}] as const;`;
350
+ const appFeatures = oneLineFeatures.length <= MAX_LINE_WIDTH
351
+ ? oneLineFeatures
352
+ : `export const APP_FEATURES = [\n${callExprs.map((c) => ` ${c},`).join("\n")}\n] as const;`;
353
+ return `// Single source of truth for your app's feature composition.
354
+ // config/user/tenant/auth-email-password are added automatically
355
+ // via composeFeatures(includeBundled:true) when runProdApp is called
356
+ // with \`auth: {…}\` (see bin/main.ts).
357
+ //
358
+ // Add new features:
359
+ // - bunx @cosmicdrift/kumiko-cli add feature <name> (DX-2, automatic)
360
+ // - or: hand-edit + add the import below
361
+
362
+ ${renderSortedImports(imports)}
363
+
364
+ ${appFeatures}
365
+
366
+ export const HAS_AUTH = true;
367
+ `;
368
+ }
369
+ function renderAdminAuthBlock(appName, extraAdminLines) {
370
+ const passwordAndExtras = extraAdminLines.map((l) => ` ${l}`).join("\n");
371
+ return ` auth: {
372
+ admin: {
373
+ email: "admin@${appName}.local",
374
+ ${passwordAndExtras}
375
+ memberships: [
376
+ {
377
+ tenantId: DEFAULT_TENANT_ID,
378
+ tenantKey: "${appName}",
379
+ tenantName: "${appName}",
380
+ roles: ["TenantAdmin"],
381
+ },
382
+ ],
383
+ },
384
+ },`;
345
385
  }
346
386
  function renderMain(appName) {
347
387
  const tenantId = deriveTenantId(appName);
348
- const project = newTsProject();
349
- const sf = project.createSourceFile("main.ts", "");
350
- sf.addImportDeclaration({
351
- moduleSpecifier: "@cosmicdrift/kumiko-dev-server/env-schema",
352
- namedImports: ["frameworkCoreEnvSchema"],
353
- });
354
- sf.addImportDeclaration({
355
- moduleSpecifier: "@cosmicdrift/kumiko-server-runtime",
356
- namedImports: ["composeFeatures", "runProdApp"],
357
- });
358
- sf.addImportDeclaration({
359
- moduleSpecifier: "@cosmicdrift/kumiko-framework/engine",
360
- isTypeOnly: true,
361
- namedImports: ["TenantId"],
362
- });
363
- sf.addImportDeclaration({
364
- moduleSpecifier: "@cosmicdrift/kumiko-framework/env",
365
- namedImports: ["composeEnvSchema"],
366
- });
367
- sf.addImportDeclaration({
368
- moduleSpecifier: "@cosmicdrift/kumiko-framework/crypto",
369
- namedImports: ["requireKmsWiring", "resolveKmsWiring"],
370
- });
371
- sf.addImportDeclaration({
372
- moduleSpecifier: "../src/run-config",
373
- namedImports: ["APP_FEATURES", "HAS_AUTH"],
374
- });
375
- sf.addVariableStatement({
376
- declarationKind: VariableDeclarationKind.Const,
377
- declarations: [
378
- {
379
- name: "DEFAULT_TENANT_ID",
380
- initializer: `"${tenantId}" as TenantId`,
381
- },
382
- ],
383
- });
384
- // The envSchema must cover the SAME features runProdApp mounts at boot.
385
- // `auth: { admin: … }` below makes runProdApp auto-mix config/user/tenant/
386
- // auth-email-password via composeFeatures(includeBundled:HAS_AUTH); compose the
387
- // identical set here so the auth feature's JWT_SECRET (min-32) declaration
388
- // is part of the boot-gate — otherwise a too-short JWT_SECRET slips through.
389
- sf.addVariableStatement({
390
- declarationKind: VariableDeclarationKind.Const,
391
- declarations: [
392
- {
393
- name: "bootFeatures",
394
- initializer: "composeFeatures(APP_FEATURES, { includeBundled: HAS_AUTH })",
395
- },
396
- ],
397
- });
398
- sf.addVariableStatement({
399
- declarationKind: VariableDeclarationKind.Const,
400
- declarations: [
401
- {
402
- name: "envSchema",
403
- initializer: "composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures })",
404
- },
405
- ],
406
- });
407
- // Subject-key KMS for the pii-annotated entities the --yes recommended set
408
- // mounts (user, tenant-invitation, fileRef). Local/dev may omit the trio
409
- // (plaintext + loud warning); NODE_ENV=production uses requireKmsWiring so
410
- // a real deploy cannot silently store plaintext PII (fw#2317 / #2339).
411
- // assertPiiBootInvariants already warns on the plaintext path — no duplicate console.warn.
412
- sf.addVariableStatement({
413
- declarationKind: VariableDeclarationKind.Const,
414
- declarations: [
415
- {
416
- name: "kmsOpts",
417
- initializer: (writer) => {
418
- writer.write("{").newLine();
419
- writer.writeLine(` logPrefix: "[${appName}]",`);
420
- writer.writeLine(` plaintextReason: "no PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY set — see .env.example",`);
421
- writer.write("} as const");
422
- },
423
- },
424
- ],
425
- });
426
- sf.addVariableStatement({
427
- declarationKind: VariableDeclarationKind.Const,
428
- declarations: [
429
- {
430
- name: "kmsWiring",
431
- initializer: (writer) => {
432
- writer.write(`process.env["NODE_ENV"] === "production"`);
433
- writer.newLine();
434
- writer.write(" ? requireKmsWiring(process.env, kmsOpts)");
435
- writer.newLine();
436
- writer.write(" : resolveKmsWiring(process.env, kmsOpts)");
437
- },
438
- },
439
- ],
440
- });
441
- sf.addStatements((writer) => {
442
- writer
443
- .write("await runProdApp(")
444
- .inlineBlock(() => {
445
- writer.writeLine("features: APP_FEATURES,");
446
- writer.writeLine("envSchema,");
447
- writer.writeLine('staticDir: "./dist",');
448
- writer.writeLine("...kmsWiring,");
449
- writer.write("auth: ").inlineBlock(() => {
450
- writer.write("admin: ").inlineBlock(() => {
451
- writer.writeLine(`email: "admin@${appName}.local",`);
452
- writer.writeLine(`password: "change-me-on-first-deploy",`);
453
- writer.writeLine(`displayName: "Admin",`);
454
- writer.write("memberships: [");
455
- writer.indent(() => {
456
- writer.inlineBlock(() => {
457
- writer.writeLine("tenantId: DEFAULT_TENANT_ID,");
458
- writer.writeLine(`tenantKey: "${appName}",`);
459
- writer.writeLine(`tenantName: "${appName}",`);
460
- writer.writeLine(`roles: ["TenantAdmin"],`);
461
- });
462
- writer.write(",");
463
- });
464
- writer.write("],");
465
- });
466
- });
467
- })
468
- .write(");");
469
- });
470
- sf.insertText(0, [
471
- "// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after",
472
- "// composeFeatures + validateBoot + createRegistry without DB/Redis-connect",
473
- "// (see @cosmicdrift/kumiko-server-runtime runProdApp). The real dev boot",
474
- "// runs via `bunx kumiko dev` (in-repo dev-tool) with a Docker stack — DX-1.0",
475
- "// only covers the boot-mode path; `kumiko dev` lands in a later DX phase.",
476
- "",
477
- "",
478
- ].join("\n"));
479
- return sf.getFullText();
388
+ const imports = renderSortedImports([
389
+ { specifier: "@cosmicdrift/kumiko-dev-server/env-schema", names: ["frameworkCoreEnvSchema"] },
390
+ { specifier: "@cosmicdrift/kumiko-server-runtime", names: ["composeFeatures", "runProdApp"] },
391
+ { specifier: "@cosmicdrift/kumiko-framework/engine", names: ["TenantId"], typeOnly: true },
392
+ { specifier: "@cosmicdrift/kumiko-framework/env", names: ["composeEnvSchema"] },
393
+ {
394
+ specifier: "@cosmicdrift/kumiko-framework/crypto",
395
+ names: ["requireKmsWiring", "resolveKmsWiring"],
396
+ },
397
+ { specifier: "zod", names: ["z"] },
398
+ { specifier: "../src/run-config", names: ["APP_FEATURES", "HAS_AUTH"] },
399
+ ]);
400
+ const authBlock = renderAdminAuthBlock(appName, [
401
+ "// runProdApp validates envSchema before seeding: a missing or short value aborts the boot.",
402
+ 'password: process.env["DEMO_ADMIN_PASSWORD"] ?? "",',
403
+ 'displayName: "Admin",',
404
+ ]);
405
+ // The envSchema must cover the SAME features runProdApp mounts at boot:
406
+ // `auth: { admin: … }` makes runProdApp auto-mix config/user/tenant/
407
+ // auth-email-password, so the JWT_SECRET (min-32) declaration has to be part
408
+ // of the boot-gate here too. Subject-key KMS is optional locally (plaintext +
409
+ // loud warning) but required under NODE_ENV=production (fw#2317 / #2339).
410
+ return `// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
411
+ // composeFeatures + validateBoot + createRegistry without DB/Redis-connect
412
+ // (see @cosmicdrift/kumiko-server-runtime runProdApp). The real dev boot
413
+ // runs via \`bunx kumiko dev\` (in-repo dev-tool) with a Docker stack — DX-1.0
414
+ // only covers the boot-mode path; \`kumiko dev\` lands in a later DX phase.
415
+
416
+ ${imports}
417
+
418
+ const DEFAULT_TENANT_ID = "${tenantId}" as TenantId;
419
+
420
+ const bootFeatures = composeFeatures(APP_FEATURES, { includeBundled: HAS_AUTH });
421
+
422
+ const envSchema = composeEnvSchema({
423
+ core: frameworkCoreEnvSchema,
424
+ features: bootFeatures,
425
+ extend: z.object({
426
+ DEMO_ADMIN_PASSWORD: z
427
+ .string()
428
+ .min(12)
429
+ .describe(
430
+ "Password of the admin seeded on first boot (admin@${appName}.local). Generate with: openssl rand -hex 24",
431
+ )
432
+ .meta({ kumiko: { pulumi: { secret: true } } }),
433
+ }),
434
+ });
435
+
436
+ const kmsOpts = {
437
+ logPrefix: "[${appName}]",
438
+ plaintextReason:
439
+ "no PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY set — see .env.example",
440
+ } as const;
441
+
442
+ const kmsWiring =
443
+ process.env["NODE_ENV"] === "production"
444
+ ? requireKmsWiring(process.env, kmsOpts)
445
+ : resolveKmsWiring(process.env, kmsOpts);
446
+
447
+ await runProdApp({
448
+ features: APP_FEATURES,
449
+ envSchema,
450
+ staticDir: "./dist",
451
+ ...kmsWiring,
452
+ ${authBlock}
453
+ });
454
+ `;
480
455
  }
481
456
  function renderDev(appName) {
482
457
  const tenantId = deriveTenantId(appName);
483
- const project = newTsProject();
484
- const sf = project.createSourceFile("dev.ts", "");
485
- sf.addImportDeclaration({
486
- moduleSpecifier: "@cosmicdrift/kumiko-dev-server",
487
- namedImports: ["runDevApp"],
488
- });
489
- sf.addImportDeclaration({
490
- moduleSpecifier: "@cosmicdrift/kumiko-framework/engine",
491
- isTypeOnly: true,
492
- namedImports: ["TenantId"],
493
- });
494
- sf.addImportDeclaration({
495
- moduleSpecifier: "../src/run-config",
496
- namedImports: ["APP_FEATURES"],
497
- });
498
- sf.addImportDeclaration({
499
- moduleSpecifier: "../src/seed",
500
- namedImports: ["seedDemoTasks"],
501
- });
502
- sf.addVariableStatement({
503
- declarationKind: VariableDeclarationKind.Const,
504
- declarations: [
505
- {
506
- name: "DEFAULT_TENANT_ID",
507
- initializer: `"${tenantId}" as TenantId`,
508
- },
509
- ],
510
- });
511
- sf.addStatements((writer) => {
512
- writer
513
- .write("await runDevApp(")
514
- .inlineBlock(() => {
515
- writer.writeLine("features: APP_FEATURES,");
516
- writer.writeLine("welcomeBanner: true,");
517
- writer.writeLine(`clientEntry: "./src/client.tsx",`);
518
- writer.writeLine("seeds: [seedDemoTasks],");
519
- writer.write("auth: ").inlineBlock(() => {
520
- writer.write("admin: ").inlineBlock(() => {
521
- writer.writeLine(`email: "admin@${appName}.local",`);
522
- writer.writeLine(`password: "changeme",`);
523
- writer.writeLine(`displayName: "Admin",`);
524
- // Dev-only: skip the email-verify login gate for the seeded
525
- // admin. Prod's scaffold (renderMain) leaves this unset on
526
- // purpose — the verify flow must run there.
527
- writer.writeLine("emailVerified: true,");
528
- writer.write("memberships: [");
529
- writer.indent(() => {
530
- writer.inlineBlock(() => {
531
- writer.writeLine("tenantId: DEFAULT_TENANT_ID,");
532
- writer.writeLine(`tenantKey: "${appName}",`);
533
- writer.writeLine(`tenantName: "${appName}",`);
534
- writer.writeLine(`roles: ["TenantAdmin"],`);
535
- });
536
- writer.write(",");
537
- });
538
- writer.write("],");
539
- });
540
- });
541
- })
542
- .write(");");
543
- });
544
- sf.insertText(0, [
545
- "// Dev bootstrap. `bun --watch bin/dev.ts` (see package.json scripts.dev)",
546
- "// starts a full-featured dev server with auto-reload on code changes.",
547
- "// setupTestStack creates missing entity tables automatically — a new",
548
- "// r.entity(...) in a feature becomes a CREATE TABLE on the next reboot,",
549
- "// no manual `kumiko schema apply` needed (that's prod-only).",
550
- "// Persistent DB via KUMIKO_DEV_DB_NAME (.env) so admin + data survive reboots.",
551
- "",
552
- "",
553
- ].join("\n"));
554
- return sf.getFullText();
458
+ const imports = renderSortedImports([
459
+ { specifier: "@cosmicdrift/kumiko-dev-server", names: ["runDevApp"] },
460
+ { specifier: "@cosmicdrift/kumiko-framework/engine", names: ["TenantId"], typeOnly: true },
461
+ { specifier: "../src/run-config", names: ["APP_FEATURES"] },
462
+ { specifier: "../src/seed", names: ["seedDemoTasks"] },
463
+ ]);
464
+ // Dev-only emailVerified skips the login verify gate for the seeded admin;
465
+ // prod's bin/main.ts leaves it unset because the verify flow must run there.
466
+ const authBlock = renderAdminAuthBlock(appName, [
467
+ 'password: "changeme",',
468
+ 'displayName: "Admin",',
469
+ "emailVerified: true,",
470
+ ]);
471
+ return `// Dev bootstrap. \`bun --watch bin/dev.ts\` (see package.json scripts.dev)
472
+ // starts a full-featured dev server with auto-reload on code changes.
473
+ // setupTestStack creates missing entity tables automatically — a new
474
+ // r.entity(...) in a feature becomes a CREATE TABLE on the next reboot,
475
+ // no manual \`kumiko schema apply\` needed (that's prod-only).
476
+ // Persistent DB via KUMIKO_DEV_DB_NAME (.env) so admin + data survive reboots.
477
+
478
+ ${imports}
479
+
480
+ const DEFAULT_TENANT_ID = "${tenantId}" as TenantId;
481
+
482
+ await runDevApp({
483
+ features: APP_FEATURES,
484
+ welcomeBanner: true,
485
+ clientEntry: "./src/client.tsx",
486
+ seeds: [seedDemoTasks],
487
+ ${authBlock}
488
+ });
489
+ `;
490
+ }
491
+ // Tiers mirror how Biome wraps the JSX as the name grows.
492
+ function renderDefaultAppShellElement(appName) {
493
+ const spanOpen = '<span className="font-semibold tracking-tight">';
494
+ // html-ok: appName is kebab-validated (a-z/0-9/-); this is generated .tsx source, not runtime HTML.
495
+ const brandInline = `${spanOpen}${appName}</span>`;
496
+ const singleLine = ` <DefaultAppShell brand={${brandInline}} schema={schema}>`;
497
+ if (singleLine.length <= MAX_LINE_WIDTH)
498
+ return singleLine;
499
+ const brandAttrInline = ` brand={${brandInline}}`;
500
+ const brandAttribute = brandAttrInline.length <= MAX_LINE_WIDTH
501
+ ? brandAttrInline
502
+ : `${[
503
+ " brand={",
504
+ ...(` ${brandInline}`.length <= MAX_LINE_WIDTH
505
+ ? [` ${brandInline}`]
506
+ : [` ${spanOpen}`, ` ${appName}`, " </span>"]),
507
+ " }",
508
+ ].join("\n")}`;
509
+ return [" <DefaultAppShell", brandAttribute, " schema={schema}", " >"].join("\n");
555
510
  }
556
511
  function renderClient(appName) {
557
- return [
558
- "// Browser entry. runDevApp's clientEntry option bundles this file to",
559
- "// /client.js and the default HTML loads it. createKumikoApp fetches the",
560
- "// AppSchema itself from the authenticated GET /api/schema and mounts",
561
- "// the routes.",
562
- "//",
563
- "// AppShell wraps DefaultAppShell to supply `brand` — createKumikoApp's",
564
- "// shell option only injects schema + children. Without `shell` the",
565
- "// active screen renders with no layout wrapper (a bare banner after",
566
- "// login instead of the app). emailPasswordClient() brings the login",
567
- "// screen + session provider — without it /login stays empty.",
568
- "//",
569
- "// Add new client plugins (e.g. notificationsClient()) to clientFeatures",
570
- "// here — symmetric to APP_FEATURES on the server side.",
571
- "",
572
- 'import { emailPasswordClient } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";',
573
- 'import { tasksClient } from "./features/tasks/web";',
574
- 'import { type AppSchema, createKumikoApp, DefaultAppShell } from "@cosmicdrift/kumiko-renderer-web";',
575
- 'import type { ReactNode } from "react";',
576
- "",
577
- "function AppShell({ children, schema }: { children: ReactNode; schema: AppSchema }): ReactNode {",
578
- " return (",
579
- // html-ok: appName is kebab-validated (a-z/0-9/-); this is generated .tsx source, not runtime HTML.
580
- ` <DefaultAppShell brand={<span className="font-semibold tracking-tight">${appName}</span>} schema={schema}>`,
581
- " {children}",
582
- " </DefaultAppShell>",
583
- " );",
584
- "}",
585
- "",
586
- "createKumikoApp({",
587
- " shell: AppShell,",
588
- " clientFeatures: [emailPasswordClient(), tasksClient],",
589
- "});",
590
- "",
591
- ].join("\n");
512
+ const imports = renderSortedImports([
513
+ {
514
+ specifier: "@cosmicdrift/kumiko-bundled-features/auth-email-password/web",
515
+ names: ["emailPasswordClient"],
516
+ },
517
+ { specifier: "./features/tasks/web", names: ["tasksClient"] },
518
+ {
519
+ specifier: "@cosmicdrift/kumiko-renderer-web",
520
+ names: ["type AppSchema", "createKumikoApp", "DefaultAppShell"],
521
+ },
522
+ { specifier: "react", names: ["ReactNode"], typeOnly: true },
523
+ ]);
524
+ return `// Browser entry. runDevApp's clientEntry option bundles this file to
525
+ // /client.js and the default HTML loads it. createKumikoApp fetches the
526
+ // AppSchema itself from the authenticated GET /api/schema and mounts
527
+ // the routes.
528
+ //
529
+ // AppShell wraps DefaultAppShell to supply \`brand\` — createKumikoApp's
530
+ // shell option only injects schema + children. Without \`shell\` the
531
+ // active screen renders with no layout wrapper (a bare banner after
532
+ // login instead of the app). emailPasswordClient() brings the login
533
+ // screen + session provider — without it /login stays empty.
534
+ //
535
+ // Add new client plugins (e.g. notificationsClient()) to clientFeatures
536
+ // here — symmetric to APP_FEATURES on the server side.
537
+
538
+ ${imports}
539
+
540
+ function AppShell({ children, schema }: { children: ReactNode; schema: AppSchema }): ReactNode {
541
+ return (
542
+ ${renderDefaultAppShellElement(appName)}
543
+ {children}
544
+ </DefaultAppShell>
545
+ );
546
+ }
547
+
548
+ createKumikoApp({
549
+ shell: AppShell,
550
+ clientFeatures: [emailPasswordClient(), tasksClient],
551
+ });
552
+ `;
592
553
  }
593
554
  function renderEnvExample(appName) {
594
555
  const devDb = `${appName.replace(/-/g, "_")}_dev`;
@@ -607,6 +568,11 @@ JWT_SECRET=change-me-min-32-chars-change-me-min-32
607
568
  # Generate with: openssl rand -base64 32
608
569
  KUMIKO_SECRETS_MASTER_KEY_V1=
609
570
 
571
+ # DEMO_ADMIN_PASSWORD: password of the admin seeded on first prod boot
572
+ # (admin@${appName}.local). Required by \`bun run boot\` and \`bun run start\`,
573
+ # min 12 chars. Generate with: openssl rand -hex 24
574
+ DEMO_ADMIN_PASSWORD=
575
+
610
576
  # Subject-keys KMS for PII fields (user, tenant-invitation, fileRef). All-or-none.
611
577
  # Unset → local boot with plaintext PII + warning; NODE_ENV=production requires
612
578
  # the trio (requireKmsWiring). Generate PLATFORM_KEK / KUMIKO_BLIND_INDEX_KEY
@@ -641,6 +607,7 @@ services:
641
607
  environment:
642
608
  POSTGRES_USER: postgres
643
609
  POSTGRES_PASSWORD: postgres
610
+ POSTGRES_DB: app
644
611
  ports:
645
612
  - "127.0.0.1:5432:5432"
646
613
  volumes:
@@ -691,7 +658,8 @@ bun run boot
691
658
  \`\`\`
692
659
 
693
660
  Runs \`KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts\` — validates feature composition
694
- + env schema, exits 0 without touching DB/Redis. Useful in CI.
661
+ + env schema, exits 0 without touching DB/Redis. Useful in CI. It needs
662
+ \`DEMO_ADMIN_PASSWORD\` (min 12 chars, \`openssl rand -hex 24\`) in \`.env\`.
695
663
 
696
664
  Without \`PLATFORM_KEK\` / \`SUBJECT_KEYS_DATABASE_URL\` / \`KUMIKO_BLIND_INDEX_KEY\`
697
665
  set (see \`.env.example\`), both \`bun run boot\` and \`bun run start\` boot with
@@ -706,6 +674,9 @@ bun run schema:apply # apply checked-in kumiko/migrations (needs DATABASE_URL)
706
674
  bun run start # runProdApp against dist/
707
675
  \`\`\`
708
676
 
677
+ The first prod boot seeds \`admin@${appName}.local\` with the password from
678
+ \`DEMO_ADMIN_PASSWORD\` (required, min 12 chars). Log in with that.
679
+
709
680
  After adding entities/features, regenerate migrations:
710
681
 
711
682
  \`\`\`sh
@@ -746,9 +717,9 @@ function renderKumikoSchema() {
746
717
  "// Computes table-metas from the SAME composeFeatures(APP_FEATURES) the",
747
718
  "// runtime sees (runProdApp/runDevApp) — migration and runtime cannot drift.",
748
719
  "",
749
- 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
750
720
  'import { collectTableMetas, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";',
751
721
  'import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";',
722
+ 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
752
723
  'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
753
724
  "",
754
725
  "export const FEATURES: readonly FeatureDefinition[] = composeFeatures([...APP_FEATURES], {",
@@ -779,10 +750,10 @@ function renderBinKumiko() {
779
750
  "// ad-hoc SQL. kumiko-build bundles this file to dist-server/kumiko.js.",
780
751
  "",
781
752
  'import { frameworkCoreEnvSchema } from "@cosmicdrift/kumiko-dev-server/env-schema";',
782
- 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
783
753
  'import { runConsumerCli } from "@cosmicdrift/kumiko-framework/consumer-cli";',
784
754
  'import { composeEnvSchema, kmsSlotsOf } from "@cosmicdrift/kumiko-framework/env";',
785
755
  'import { runSchemaCli } from "@cosmicdrift/kumiko-framework/schema-cli";',
756
+ 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
786
757
  'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
787
758
  "",
788
759
  "const [, , cmd, ...rest] = Bun.argv;",
@@ -792,7 +763,9 @@ function renderBinKumiko() {
792
763
  'if (cmd === "schema") {',
793
764
  " const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
794
765
  " const kmsSlots = kmsSlotsOf(composeEnvSchema({ core: frameworkCoreEnvSchema, features }).schema);",
795
- " process.exit(await runSchemaCli(rest, process.env.INIT_CWD ?? process.cwd(), out, { features, kmsSlots }));",
766
+ " process.exit(",
767
+ " await runSchemaCli(rest, process.env.INIT_CWD ?? process.cwd(), out, { features, kmsSlots }),",
768
+ " );",
796
769
  "}",
797
770
  "",
798
771
  'if (cmd === "consumer") {',
@@ -800,7 +773,9 @@ function renderBinKumiko() {
800
773
  "}",
801
774
  "",
802
775
  "// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
803
- 'console.error("\\n Unknown: kumiko " + (cmd ?? "") + " — only \'kumiko schema <sub>\' or \'kumiko consumer <sub>\' in the standalone bundle.\\n");',
776
+ "console.error(",
777
+ ` \`\\n Unknown: kumiko \${cmd ?? ""} — only 'kumiko schema <sub>' or 'kumiko consumer <sub>' in the standalone bundle.\\n\`,`,
778
+ ");",
804
779
  "process.exit(1);",
805
780
  "",
806
781
  ].join("\n");
@@ -127,7 +127,8 @@ const editScreen: EntityEditScreenDefinition = {
127
127
  const open = {
128
128
  access: {
129
129
  openToAll: {
130
- reason: "scaffolded demo handler: every signed-in user of the demo tenant manages their tasks",
130
+ reason:
131
+ "scaffolded demo handler: every signed-in user of the demo tenant manages their tasks",
131
132
  },
132
133
  },
133
134
  } as const;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.364.0",
3
+ "version": "0.365.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Its compose-stacks/env-schema subpaths are consumed at prod boot too — see @cosmicdrift/kumiko-server-runtime for the prod runner itself.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -60,17 +60,16 @@
60
60
  "kumiko-dev": "./bin/kumiko-dev.ts",
61
61
  "kumiko-init-deploy": "./bin/kumiko-init-deploy.ts",
62
62
  "kumiko-schema": "./bin/kumiko-schema.ts",
63
- "kumiko-schema-check": "./bin/kumiko-schema-check.ts",
64
63
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
65
64
  },
66
65
  "dependencies": {
67
- "@cosmicdrift/kumiko-bundled-features": "0.364.0",
68
- "@cosmicdrift/kumiko-framework": "0.364.0",
69
- "@cosmicdrift/kumiko-headless": "0.364.0",
70
- "@cosmicdrift/kumiko-renderer-mail-html": "0.364.0",
71
- "@cosmicdrift/kumiko-renderer-web": "0.364.0",
72
- "@cosmicdrift/kumiko-server-runtime": "0.364.0",
73
- "@cosmicdrift/kumiko-types": "0.364.0",
66
+ "@cosmicdrift/kumiko-bundled-features": "0.365.0",
67
+ "@cosmicdrift/kumiko-framework": "0.365.0",
68
+ "@cosmicdrift/kumiko-headless": "0.365.0",
69
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.365.0",
70
+ "@cosmicdrift/kumiko-renderer-web": "0.365.0",
71
+ "@cosmicdrift/kumiko-server-runtime": "0.365.0",
72
+ "@cosmicdrift/kumiko-types": "0.365.0",
74
73
  "bun-types": "^1.3.13",
75
74
  "ts-morph": "^28.0.0",
76
75
  "zod": "^4.4.3"
package/src/changes.json CHANGED
@@ -1,4 +1,41 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "New projects read the prod admin password from DEMO_ADMIN_PASSWORD",
6
+ "detail": "The scaffolded bin/main.ts no longer seeds the prod admin with a fixed password. It reads DEMO_ADMIN_PASSWORD (declared in the env schema, min 12 chars), and boot fails without it. Existing apps are untouched; apps from an older scaffold should change bin/main.ts the same way."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "A freshly scaffolded app passes bun run lint without rework",
12
+ "detail": "New projects now get a .gitignore, an exactly pinned @biomejs/biome and lint-clean generated files (import order, formatting). Existing apps are untouched."
13
+ },
14
+ {
15
+ "version": "0.365.0",
16
+ "type": "improvement",
17
+ "title": "composeRendererStack passes a mail brand suffix resolver",
18
+ "detail": "composeRendererStack({ mailBrandSuffix }) hands the resolver to createRendererMailHtmlFeature as brandSuffix."
19
+ },
20
+ {
21
+ "version": "0.365.0",
22
+ "type": "improvement",
23
+ "title": "composeRendererStack passes a mail footer resolver to the mail renderer",
24
+ "detail": "composeRendererStack({ mailFooter }) hands the resolver to createRendererMailHtmlFeature, so apps on the composed stack set imprint, privacy and platform line in one place."
25
+ },
26
+ {
27
+ "version": "0.365.0",
28
+ "type": "breaking",
29
+ "title": "The kumiko-schema-check bin and the resolveGeneratePath, usesDerivedSchemaLayout and implicitAuthModeFeatureNames exports are removed",
30
+ "detail": "kumiko-schema-check diffed APP_FEATURES against a hand-kept FEATURE_IMPORT_REGISTRY. Apps with kumiko/schema.ts derive their entities from the mounted features and have no registry that could drift, so the check was a no-op there. The helpers resolveGeneratePath, usesDerivedSchemaLayout and implicitAuthModeFeatureNames are no longer exported from @cosmicdrift/kumiko-dev-server/cli.",
31
+ "migration": "Apps with kumiko/schema.ts need nothing. Run kumiko-schema validate to check migrations against the mounted features, and remove kumiko-schema-check from package scripts and CI. Apps that keep a FEATURE_IMPORT_REGISTRY diff it against APP_FEATURES in their own coverage script."
32
+ },
33
+ {
34
+ "version": "0.365.0",
35
+ "type": "fix",
36
+ "title": "buildServerBundle resolves worker and bootstrap entries as .ts or .js; runDevApp stop keeps an explicit blindIndexKey",
37
+ "detail": "An app with bin/main.js and bin/worker.js used to get no dist-server/worker.js without any warning, because only worker.ts and bootstrap.ts were looked up. Siblings now resolve like the server entry, .ts first and then .js. In the ephemeral PII default, stopping runDevApp unset a blindIndexKey that the app passed itself; it now unsets only the key the default generated."
38
+ },
2
39
  {
3
40
  "version": "0.364.0",
4
41
  "type": "fix",
@@ -1,175 +0,0 @@
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
- import {
29
- implicitAuthModeFeatureNames,
30
- resolveGeneratePath,
31
- } from "@cosmicdrift/kumiko-dev-server/cli";
32
-
33
- type Args = {
34
- readonly runConfigPath: string;
35
- readonly generatePath: string;
36
- };
37
-
38
- function parseArgs(argv: readonly string[]): Args {
39
- const cwd = process.cwd();
40
- let runConfigPath = resolve(cwd, "src/run-config.ts");
41
- let generatePath: string | undefined;
42
- for (let i = 0; i < argv.length; i++) {
43
- const flag = argv[i];
44
- const value = argv[i + 1];
45
- if (flag === "--run-config" && value) {
46
- runConfigPath = resolve(cwd, value);
47
- i++;
48
- } else if (flag === "--generate" && value) {
49
- generatePath = resolve(cwd, value);
50
- i++;
51
- }
52
- }
53
- return { runConfigPath, generatePath: generatePath ?? resolveGeneratePath(cwd) };
54
- }
55
-
56
- function readRegistryFeatures(generateSrc: string): Set<string> {
57
- // Match object-keys mit Discriminator `kind: "factory" | "named"`.
58
- // Quoted ("billing-foundation") oder unquoted (config, user) — beides
59
- // ist gültig in JS. Pattern aus use-all-bundled/scripts/check-coverage.ts
60
- // dupliziert hier weil per-app-CLI nicht von sample-script lesen darf.
61
- const re = /(?:"([a-z][a-z0-9-]*)"|([a-z][a-z0-9]*)):\s*\{\s*kind:\s*"(factory|named)"/g;
62
- const out = new Set<string>();
63
- for (const m of generateSrc.matchAll(re)) {
64
- const name = m[1] ?? m[2];
65
- if (name) out.add(name);
66
- }
67
- return out;
68
- }
69
-
70
- async function readMountedFeatures(runConfigPath: string): Promise<Set<string>> {
71
- const mod = (await import(runConfigPath)) as {
72
- APP_FEATURES?: ReadonlyArray<{ name: string }>;
73
- HAS_AUTH?: boolean;
74
- HAS_SIGNUP?: boolean;
75
- };
76
- if (!mod.APP_FEATURES) {
77
- throw new Error(
78
- `kumiko-schema-check: ${runConfigPath} hat kein 'APP_FEATURES' export. ` +
79
- `Convention: 'export const APP_FEATURES = [...] as const'.`,
80
- );
81
- }
82
- const set = new Set<string>();
83
- for (const f of mod.APP_FEATURES) {
84
- set.add(f.name);
85
- }
86
- // HAS_AUTH defaults to true (Studio/use-all-bundled convention). When set,
87
- // include the implicit auth-mode features so the diff doesn't false-positive
88
- // "auth-email-password is mounted but no registry-entry".
89
- if (mod.HAS_AUTH ?? true) {
90
- for (const name of implicitAuthModeFeatureNames()) set.add(name);
91
- }
92
- // auth-self-registration is only prepended when authOptions.signup is set
93
- // (composeFeatures) — opt-in via HAS_SIGNUP so apps without signup don't
94
- // get a stale-registry false positive (#1521).
95
- if (mod.HAS_SIGNUP === true) {
96
- set.add("auth-self-registration");
97
- }
98
- return set;
99
- }
100
-
101
- const USAGE = "Usage: kumiko-schema-check [--run-config <path>] [--generate <path>]";
102
-
103
- async function main(): Promise<void> {
104
- if (process.argv.slice(2).some((arg) => arg === "--help" || arg === "-h")) {
105
- console.log(USAGE);
106
- process.exit(0);
107
- }
108
- const args = parseArgs(process.argv.slice(2));
109
-
110
- if (!existsSync(args.runConfigPath)) {
111
- console.error(`✗ run-config not found: ${args.runConfigPath}`);
112
- process.exit(1);
113
- }
114
- if (!existsSync(args.generatePath)) {
115
- console.error(`✗ generate not found: ${args.generatePath}`);
116
- process.exit(1);
117
- }
118
-
119
- const registry = readRegistryFeatures(readFileSync(args.generatePath, "utf-8"));
120
- const mounted = await readMountedFeatures(args.runConfigPath);
121
-
122
- // Mounted but not in registry → schema-drift (runtime ↔ migration mismatch).
123
- const mountedWithoutEntry: string[] = [];
124
- for (const name of mounted) {
125
- if (!registry.has(name)) mountedWithoutEntry.push(name);
126
- }
127
- // In registry but not mounted → stale entry (dead schema-mapping).
128
- const staleEntries: string[] = [];
129
- for (const name of registry) {
130
- if (!mounted.has(name)) staleEntries.push(name);
131
- }
132
-
133
- let ok = true;
134
-
135
- if (mountedWithoutEntry.length > 0) {
136
- console.error(
137
- `\n✗ ${mountedWithoutEntry.length} feature(s) mounted in APP_FEATURES but NOT in FEATURE_IMPORT_REGISTRY:`,
138
- );
139
- for (const name of mountedWithoutEntry.sort()) {
140
- console.error(` - ${name}`);
141
- }
142
- console.error(
143
- "\n Action: in drizzle/generate.ts FEATURE_IMPORT_REGISTRY den Eintrag ergänzen,",
144
- );
145
- console.error(" damit Schema-Generator + Migration die feature-Tabellen kennt.");
146
- ok = false;
147
- }
148
-
149
- if (staleEntries.length > 0) {
150
- console.error(
151
- `\n✗ ${staleEntries.length} stale FEATURE_IMPORT_REGISTRY entry/entries (kein matching mount):`,
152
- );
153
- for (const name of staleEntries.sort()) {
154
- console.error(` - ${name}`);
155
- }
156
- console.error("\n Action: entry aus drizzle/generate.ts FEATURE_IMPORT_REGISTRY entfernen,");
157
- console.error(" oder das feature in src/run-config.ts mounten.");
158
- ok = false;
159
- }
160
-
161
- if (ok) {
162
- console.log(
163
- `✓ schema check: ${mounted.size} mounted ↔ ${registry.size} registry entries, no drift`,
164
- );
165
- process.exit(0);
166
- } else {
167
- process.exit(1);
168
- }
169
- }
170
-
171
- // Only run when executed as a CLI, not when imported (e.g. from tests that
172
- // exercise the exported pure helpers).
173
- if (import.meta.main) {
174
- await main();
175
- }
@@ -1,2 +0,0 @@
1
- export declare function resolveGeneratePath(cwd: string): string;
2
- export declare function implicitAuthModeFeatureNames(): readonly string[];
@@ -1,24 +0,0 @@
1
- // Pure helpers for the `kumiko-schema-check` bin. Kept in src/ (vs. the bin)
2
- // so they are part of the tsc project + unit-testable without importing the
3
- // auto-running CLI.
4
- import { existsSync } from "node:fs";
5
- import { resolve } from "node:path";
6
- import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
7
- // The generate-script lives under different roots across apps: publicstatus
8
- // uses `drizzle/generate.ts`, the framework's own sample-apps use
9
- // `schema/generate.ts`. Pick whichever exists so a no-arg `bunx
10
- // kumiko-schema-check` works in both layouts; default to `drizzle/` for the
11
- // error message when neither is present.
12
- export function resolveGeneratePath(cwd) {
13
- const drizzlePath = resolve(cwd, "drizzle/generate.ts");
14
- const schemaPath = resolve(cwd, "schema/generate.ts");
15
- if (!existsSync(drizzlePath) && existsSync(schemaPath))
16
- return schemaPath;
17
- return drizzlePath;
18
- }
19
- // The features composeFeatures auto-prepends in auth-mode (config + user +
20
- // tenant + auth-email-password). Derived from composeFeatures itself so this
21
- // can't drift from the real prepend-list in compose-features.ts.
22
- export function implicitAuthModeFeatureNames() {
23
- return composeFeatures([], { includeBundled: true }).map((f) => f.name);
24
- }