@cosmicdrift/kumiko-dev-server 0.363.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.
@@ -6,18 +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";
17
+ import { renderAgentsMd, renderClaudeMd } from "./scaffold-agent-docs.js";
19
18
  import { createDemoTasksFeature, renderDemoSeedFile, renderDemoTasksFeatureFile, renderDemoTasksI18n, renderDemoTasksIndex, renderDemoTasksWebIndex, } from "./scaffold-demo-tasks.js";
20
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
+ `;
21
31
  export async function scaffoldApp(options) {
22
32
  if (!isKebabSegment(options.name)) {
23
33
  throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
@@ -41,6 +51,8 @@ export async function scaffoldApp(options) {
41
51
  files.push("kumiko.json");
42
52
  write(join(destination, "biome.json"), renderBiomeJson());
43
53
  files.push("biome.json");
54
+ write(join(destination, ".gitignore"), SCAFFOLD_GITIGNORE);
55
+ files.push(".gitignore");
44
56
  if (testSetup === undefined) {
45
57
  write(join(destination, "bunfig.toml"), renderBunfigToml());
46
58
  files.push("bunfig.toml");
@@ -93,6 +105,10 @@ export async function scaffoldApp(options) {
93
105
  }
94
106
  write(join(destination, "README.md"), `${renderReadme(options.name, options.features)}${testSetup === undefined ? "" : `\n${testSetup.rulesMarkdown}`}`);
95
107
  files.push("README.md");
108
+ write(join(destination, "AGENTS.md"), renderAgentsMd());
109
+ files.push("AGENTS.md");
110
+ write(join(destination, "CLAUDE.md"), renderClaudeMd());
111
+ files.push("CLAUDE.md");
96
112
  return { destination, files, appName: options.name };
97
113
  }
98
114
  // @wrapper-known semantic-alias
@@ -127,7 +143,7 @@ function renderPackageJson(name, version, testSetup) {
127
143
  zod: "^4.4.3",
128
144
  },
129
145
  devDependencies: sortedByName({
130
- "@biomejs/biome": "^2.4.15",
146
+ "@biomejs/biome": SCAFFOLD_BIOME_VERSION,
131
147
  "@tailwindcss/cli": "^4.3.0",
132
148
  "@types/react": "^19.2.0",
133
149
  "@types/react-dom": "^19.2.0",
@@ -163,7 +179,7 @@ function renderTsconfig() {
163
179
  }
164
180
  function renderBiomeJson() {
165
181
  return `${JSON.stringify({
166
- $schema: "https://biomejs.dev/schemas/2.4.15/schema.json",
182
+ $schema: `https://biomejs.dev/schemas/${SCAFFOLD_BIOME_VERSION}/schema.json`,
167
183
  vcs: {
168
184
  enabled: true,
169
185
  clientKind: "git",
@@ -204,7 +220,7 @@ function renderBiomeJson() {
204
220
  linter: {
205
221
  enabled: true,
206
222
  rules: {
207
- recommended: true,
223
+ preset: "recommended",
208
224
  correctness: { noUnusedVariables: "error", noUnusedImports: "error" },
209
225
  suspicious: { noExplicitAny: "error", noDebugger: "error", noConsole: "warn" },
210
226
  complexity: { useLiteralKeys: "off" },
@@ -249,13 +265,6 @@ pathIgnorePatterns = [
249
265
  ]
250
266
  `;
251
267
  }
252
- function newTsProject() {
253
- return new Project({
254
- useInMemoryFileSystem: true,
255
- compilerOptions: { target: 99, module: 99, strict: true },
256
- manipulationSettings: { indentationText: IndentationText.TwoSpaces },
257
- });
258
- }
259
268
  const FOUNDATION_FEATURES = [
260
269
  {
261
270
  name: "secrets",
@@ -284,9 +293,40 @@ const FOUNDATION_FEATURES = [
284
293
  // jemand scaffoldApp() direkt mit allen Bundled-Feature-Entries aufruft
285
294
  // rutschen die 4 nicht in run-config.ts.
286
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
+ }
287
329
  function renderRunConfig(features) {
288
- const project = newTsProject();
289
- const sf = project.createSourceFile("run-config.ts", "");
290
330
  // Fallback decided BEFORE filtering: if the caller passed features (even
291
331
  // if every one of them is an auto-mounted name), an all-filtered-out empty
292
332
  // result is the correct outcome — falling back to FOUNDATION_FEATURES here
@@ -301,289 +341,215 @@ function renderRunConfig(features) {
301
341
  existing.push(entry.exportName);
302
342
  grouped.set(entry.importPath, existing);
303
343
  }
304
- for (const [importPath, namedImports] of grouped) {
305
- sf.addImportDeclaration({ moduleSpecifier: importPath, namedImports });
306
- }
307
- sf.addImportDeclaration({
308
- moduleSpecifier: "./features/tasks",
309
- namedImports: ["tasksFeature"],
310
- });
344
+ const imports = [
345
+ ...[...grouped].map(([specifier, names]) => ({ specifier, names })),
346
+ { specifier: "./features/tasks", names: ["tasksFeature"] },
347
+ ];
311
348
  const callExprs = [...effective.map((f) => f.callExpression), "tasksFeature"];
312
- sf.addVariableStatement({
313
- declarationKind: VariableDeclarationKind.Const,
314
- isExported: true,
315
- declarations: [
316
- {
317
- name: "APP_FEATURES",
318
- initializer: `[${callExprs.join(", ")}] as const`,
319
- },
320
- ],
321
- });
322
- sf.addVariableStatement({
323
- declarationKind: VariableDeclarationKind.Const,
324
- isExported: true,
325
- declarations: [{ name: "HAS_AUTH", initializer: "true" }],
326
- });
327
- sf.insertText(0, [
328
- "// Single source of truth for your app's feature composition.",
329
- "// config/user/tenant/auth-email-password are added automatically",
330
- "// via composeFeatures(includeBundled:true) when runProdApp is called",
331
- "// with `auth: {…}` (see bin/main.ts).",
332
- "//",
333
- "// Add new features:",
334
- "// - bunx @cosmicdrift/kumiko-cli add feature <name> (DX-2, automatic)",
335
- "// - or: hand-edit + add the import below",
336
- "",
337
- "",
338
- ].join("\n"));
339
- 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
+ },`;
340
385
  }
341
386
  function renderMain(appName) {
342
387
  const tenantId = deriveTenantId(appName);
343
- const project = newTsProject();
344
- const sf = project.createSourceFile("main.ts", "");
345
- sf.addImportDeclaration({
346
- moduleSpecifier: "@cosmicdrift/kumiko-dev-server/env-schema",
347
- namedImports: ["frameworkCoreEnvSchema"],
348
- });
349
- sf.addImportDeclaration({
350
- moduleSpecifier: "@cosmicdrift/kumiko-server-runtime",
351
- namedImports: ["composeFeatures", "runProdApp"],
352
- });
353
- sf.addImportDeclaration({
354
- moduleSpecifier: "@cosmicdrift/kumiko-framework/engine",
355
- isTypeOnly: true,
356
- namedImports: ["TenantId"],
357
- });
358
- sf.addImportDeclaration({
359
- moduleSpecifier: "@cosmicdrift/kumiko-framework/env",
360
- namedImports: ["composeEnvSchema"],
361
- });
362
- sf.addImportDeclaration({
363
- moduleSpecifier: "@cosmicdrift/kumiko-framework/crypto",
364
- namedImports: ["requireKmsWiring", "resolveKmsWiring"],
365
- });
366
- sf.addImportDeclaration({
367
- moduleSpecifier: "../src/run-config",
368
- namedImports: ["APP_FEATURES", "HAS_AUTH"],
369
- });
370
- sf.addVariableStatement({
371
- declarationKind: VariableDeclarationKind.Const,
372
- declarations: [
373
- {
374
- name: "DEFAULT_TENANT_ID",
375
- initializer: `"${tenantId}" as TenantId`,
376
- },
377
- ],
378
- });
379
- // The envSchema must cover the SAME features runProdApp mounts at boot.
380
- // `auth: { admin: … }` below makes runProdApp auto-mix config/user/tenant/
381
- // auth-email-password via composeFeatures(includeBundled:HAS_AUTH); compose the
382
- // identical set here so the auth feature's JWT_SECRET (min-32) declaration
383
- // is part of the boot-gate — otherwise a too-short JWT_SECRET slips through.
384
- sf.addVariableStatement({
385
- declarationKind: VariableDeclarationKind.Const,
386
- declarations: [
387
- {
388
- name: "bootFeatures",
389
- initializer: "composeFeatures(APP_FEATURES, { includeBundled: HAS_AUTH })",
390
- },
391
- ],
392
- });
393
- sf.addVariableStatement({
394
- declarationKind: VariableDeclarationKind.Const,
395
- declarations: [
396
- {
397
- name: "envSchema",
398
- initializer: "composeEnvSchema({ core: frameworkCoreEnvSchema, features: bootFeatures })",
399
- },
400
- ],
401
- });
402
- // Subject-key KMS for the pii-annotated entities the --yes recommended set
403
- // mounts (user, tenant-invitation, fileRef). Local/dev may omit the trio
404
- // (plaintext + loud warning); NODE_ENV=production uses requireKmsWiring so
405
- // a real deploy cannot silently store plaintext PII (fw#2317 / #2339).
406
- // assertPiiBootInvariants already warns on the plaintext path — no duplicate console.warn.
407
- sf.addVariableStatement({
408
- declarationKind: VariableDeclarationKind.Const,
409
- declarations: [
410
- {
411
- name: "kmsOpts",
412
- initializer: (writer) => {
413
- writer.write("{").newLine();
414
- writer.writeLine(` logPrefix: "[${appName}]",`);
415
- writer.writeLine(` plaintextReason: "no PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY set — see .env.example",`);
416
- writer.write("} as const");
417
- },
418
- },
419
- ],
420
- });
421
- sf.addVariableStatement({
422
- declarationKind: VariableDeclarationKind.Const,
423
- declarations: [
424
- {
425
- name: "kmsWiring",
426
- initializer: (writer) => {
427
- writer.write(`process.env["NODE_ENV"] === "production"`);
428
- writer.newLine();
429
- writer.write(" ? requireKmsWiring(process.env, kmsOpts)");
430
- writer.newLine();
431
- writer.write(" : resolveKmsWiring(process.env, kmsOpts)");
432
- },
433
- },
434
- ],
435
- });
436
- sf.addStatements((writer) => {
437
- writer
438
- .write("await runProdApp(")
439
- .inlineBlock(() => {
440
- writer.writeLine("features: APP_FEATURES,");
441
- writer.writeLine("envSchema,");
442
- writer.writeLine('staticDir: "./dist",');
443
- writer.writeLine("...kmsWiring,");
444
- writer.write("auth: ").inlineBlock(() => {
445
- writer.write("admin: ").inlineBlock(() => {
446
- writer.writeLine(`email: "admin@${appName}.local",`);
447
- writer.writeLine(`password: "change-me-on-first-deploy",`);
448
- writer.writeLine(`displayName: "Admin",`);
449
- writer.write("memberships: [");
450
- writer.indent(() => {
451
- writer.inlineBlock(() => {
452
- writer.writeLine("tenantId: DEFAULT_TENANT_ID,");
453
- writer.writeLine(`tenantKey: "${appName}",`);
454
- writer.writeLine(`tenantName: "${appName}",`);
455
- writer.writeLine(`roles: ["TenantAdmin"],`);
456
- });
457
- writer.write(",");
458
- });
459
- writer.write("],");
460
- });
461
- });
462
- })
463
- .write(");");
464
- });
465
- sf.insertText(0, [
466
- "// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after",
467
- "// composeFeatures + validateBoot + createRegistry without DB/Redis-connect",
468
- "// (see @cosmicdrift/kumiko-server-runtime runProdApp). The real dev boot",
469
- "// runs via `bunx kumiko dev` (in-repo dev-tool) with a Docker stack — DX-1.0",
470
- "// only covers the boot-mode path; `kumiko dev` lands in a later DX phase.",
471
- "",
472
- "",
473
- ].join("\n"));
474
- 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
+ `;
475
455
  }
476
456
  function renderDev(appName) {
477
457
  const tenantId = deriveTenantId(appName);
478
- const project = newTsProject();
479
- const sf = project.createSourceFile("dev.ts", "");
480
- sf.addImportDeclaration({
481
- moduleSpecifier: "@cosmicdrift/kumiko-dev-server",
482
- namedImports: ["runDevApp"],
483
- });
484
- sf.addImportDeclaration({
485
- moduleSpecifier: "@cosmicdrift/kumiko-framework/engine",
486
- isTypeOnly: true,
487
- namedImports: ["TenantId"],
488
- });
489
- sf.addImportDeclaration({
490
- moduleSpecifier: "../src/run-config",
491
- namedImports: ["APP_FEATURES"],
492
- });
493
- sf.addImportDeclaration({
494
- moduleSpecifier: "../src/seed",
495
- namedImports: ["seedDemoTasks"],
496
- });
497
- sf.addVariableStatement({
498
- declarationKind: VariableDeclarationKind.Const,
499
- declarations: [
500
- {
501
- name: "DEFAULT_TENANT_ID",
502
- initializer: `"${tenantId}" as TenantId`,
503
- },
504
- ],
505
- });
506
- sf.addStatements((writer) => {
507
- writer
508
- .write("await runDevApp(")
509
- .inlineBlock(() => {
510
- writer.writeLine("features: APP_FEATURES,");
511
- writer.writeLine("welcomeBanner: true,");
512
- writer.writeLine(`clientEntry: "./src/client.tsx",`);
513
- writer.writeLine("seeds: [seedDemoTasks],");
514
- writer.write("auth: ").inlineBlock(() => {
515
- writer.write("admin: ").inlineBlock(() => {
516
- writer.writeLine(`email: "admin@${appName}.local",`);
517
- writer.writeLine(`password: "changeme",`);
518
- writer.writeLine(`displayName: "Admin",`);
519
- // Dev-only: skip the email-verify login gate for the seeded
520
- // admin. Prod's scaffold (renderMain) leaves this unset on
521
- // purpose — the verify flow must run there.
522
- writer.writeLine("emailVerified: true,");
523
- writer.write("memberships: [");
524
- writer.indent(() => {
525
- writer.inlineBlock(() => {
526
- writer.writeLine("tenantId: DEFAULT_TENANT_ID,");
527
- writer.writeLine(`tenantKey: "${appName}",`);
528
- writer.writeLine(`tenantName: "${appName}",`);
529
- writer.writeLine(`roles: ["TenantAdmin"],`);
530
- });
531
- writer.write(",");
532
- });
533
- writer.write("],");
534
- });
535
- });
536
- })
537
- .write(");");
538
- });
539
- sf.insertText(0, [
540
- "// Dev bootstrap. `bun --watch bin/dev.ts` (see package.json scripts.dev)",
541
- "// starts a full-featured dev server with auto-reload on code changes.",
542
- "// setupTestStack creates missing entity tables automatically — a new",
543
- "// r.entity(...) in a feature becomes a CREATE TABLE on the next reboot,",
544
- "// no manual `kumiko schema apply` needed (that's prod-only).",
545
- "// Persistent DB via KUMIKO_DEV_DB_NAME (.env) so admin + data survive reboots.",
546
- "",
547
- "",
548
- ].join("\n"));
549
- 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");
550
510
  }
551
511
  function renderClient(appName) {
552
- return [
553
- "// Browser entry. runDevApp's clientEntry option bundles this file to",
554
- "// /client.js and the default HTML loads it. createKumikoApp fetches the",
555
- "// AppSchema itself from the authenticated GET /api/schema and mounts",
556
- "// the routes.",
557
- "//",
558
- "// AppShell wraps DefaultAppShell to supply `brand` — createKumikoApp's",
559
- "// shell option only injects schema + children. Without `shell` the",
560
- "// active screen renders with no layout wrapper (a bare banner after",
561
- "// login instead of the app). emailPasswordClient() brings the login",
562
- "// screen + session provider — without it /login stays empty.",
563
- "//",
564
- "// Add new client plugins (e.g. notificationsClient()) to clientFeatures",
565
- "// here — symmetric to APP_FEATURES on the server side.",
566
- "",
567
- 'import { emailPasswordClient } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";',
568
- 'import { tasksClient } from "./features/tasks/web";',
569
- 'import { type AppSchema, createKumikoApp, DefaultAppShell } from "@cosmicdrift/kumiko-renderer-web";',
570
- 'import type { ReactNode } from "react";',
571
- "",
572
- "function AppShell({ children, schema }: { children: ReactNode; schema: AppSchema }): ReactNode {",
573
- " return (",
574
- // html-ok: appName is kebab-validated (a-z/0-9/-); this is generated .tsx source, not runtime HTML.
575
- ` <DefaultAppShell brand={<span className="font-semibold tracking-tight">${appName}</span>} schema={schema}>`,
576
- " {children}",
577
- " </DefaultAppShell>",
578
- " );",
579
- "}",
580
- "",
581
- "createKumikoApp({",
582
- " shell: AppShell,",
583
- " clientFeatures: [emailPasswordClient(), tasksClient],",
584
- "});",
585
- "",
586
- ].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
+ `;
587
553
  }
588
554
  function renderEnvExample(appName) {
589
555
  const devDb = `${appName.replace(/-/g, "_")}_dev`;
@@ -602,6 +568,11 @@ JWT_SECRET=change-me-min-32-chars-change-me-min-32
602
568
  # Generate with: openssl rand -base64 32
603
569
  KUMIKO_SECRETS_MASTER_KEY_V1=
604
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
+
605
576
  # Subject-keys KMS for PII fields (user, tenant-invitation, fileRef). All-or-none.
606
577
  # Unset → local boot with plaintext PII + warning; NODE_ENV=production requires
607
578
  # the trio (requireKmsWiring). Generate PLATFORM_KEK / KUMIKO_BLIND_INDEX_KEY
@@ -636,6 +607,7 @@ services:
636
607
  environment:
637
608
  POSTGRES_USER: postgres
638
609
  POSTGRES_PASSWORD: postgres
610
+ POSTGRES_DB: app
639
611
  ports:
640
612
  - "127.0.0.1:5432:5432"
641
613
  volumes:
@@ -686,7 +658,8 @@ bun run boot
686
658
  \`\`\`
687
659
 
688
660
  Runs \`KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts\` — validates feature composition
689
- + 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\`.
690
663
 
691
664
  Without \`PLATFORM_KEK\` / \`SUBJECT_KEYS_DATABASE_URL\` / \`KUMIKO_BLIND_INDEX_KEY\`
692
665
  set (see \`.env.example\`), both \`bun run boot\` and \`bun run start\` boot with
@@ -701,6 +674,9 @@ bun run schema:apply # apply checked-in kumiko/migrations (needs DATABASE_URL)
701
674
  bun run start # runProdApp against dist/
702
675
  \`\`\`
703
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
+
704
680
  After adding entities/features, regenerate migrations:
705
681
 
706
682
  \`\`\`sh
@@ -741,9 +717,9 @@ function renderKumikoSchema() {
741
717
  "// Computes table-metas from the SAME composeFeatures(APP_FEATURES) the",
742
718
  "// runtime sees (runProdApp/runDevApp) — migration and runtime cannot drift.",
743
719
  "",
744
- 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
745
720
  'import { collectTableMetas, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";',
746
721
  'import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";',
722
+ 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
747
723
  'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
748
724
  "",
749
725
  "export const FEATURES: readonly FeatureDefinition[] = composeFeatures([...APP_FEATURES], {",
@@ -774,10 +750,10 @@ function renderBinKumiko() {
774
750
  "// ad-hoc SQL. kumiko-build bundles this file to dist-server/kumiko.js.",
775
751
  "",
776
752
  'import { frameworkCoreEnvSchema } from "@cosmicdrift/kumiko-dev-server/env-schema";',
777
- 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
778
753
  'import { runConsumerCli } from "@cosmicdrift/kumiko-framework/consumer-cli";',
779
754
  'import { composeEnvSchema, kmsSlotsOf } from "@cosmicdrift/kumiko-framework/env";',
780
755
  'import { runSchemaCli } from "@cosmicdrift/kumiko-framework/schema-cli";',
756
+ 'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
781
757
  'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
782
758
  "",
783
759
  "const [, , cmd, ...rest] = Bun.argv;",
@@ -787,7 +763,9 @@ function renderBinKumiko() {
787
763
  'if (cmd === "schema") {',
788
764
  " const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
789
765
  " const kmsSlots = kmsSlotsOf(composeEnvSchema({ core: frameworkCoreEnvSchema, features }).schema);",
790
- " 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
+ " );",
791
769
  "}",
792
770
  "",
793
771
  'if (cmd === "consumer") {',
@@ -795,7 +773,9 @@ function renderBinKumiko() {
795
773
  "}",
796
774
  "",
797
775
  "// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
798
- '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
+ ");",
799
779
  "process.exit(1);",
800
780
  "",
801
781
  ].join("\n");