@cosmicdrift/kumiko-dev-server 0.409.0 → 0.411.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/dist/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export type { ScaffoldAppOptions, ScaffoldAppResult, ScaffoldFeatureEntry, } fro
16
16
  export { SCAFFOLD_BIOME_VERSION, scaffoldApp } from "./scaffold-app.ts";
17
17
  export type { ScaffoldAppFeatureOptions, ScaffoldAppFeatureResult, } from "./scaffold-app-feature.ts";
18
18
  export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature.ts";
19
+ export { SCAFFOLD_STARTERS, type ScaffoldStarter } from "./scaffold-blank-starter.ts";
19
20
  export type { CheckDeployDriftResult, DeployDriftEntry, RenderDeployFilesOptions, RenderDeployFilesResult, RenderedDeployFile, ScaffoldDeployDetected, ScaffoldDeployOptions, ScaffoldDeployResult, ScaffoldedFile, } from "./scaffold-deploy.ts";
20
21
  export { checkDeployDrift, renderDeployFiles, scaffoldDeploy } from "./scaffold-deploy.ts";
21
22
  export type { ScaffoldFeatureOptions, ScaffoldFeatureResult } from "./scaffold-feature.ts";
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export { runInitDeployCli } from "./init-deploy-cli.js";
29
29
  export { runDevApp } from "./run-dev-app.js";
30
30
  export { SCAFFOLD_BIOME_VERSION, scaffoldApp } from "./scaffold-app.js";
31
31
  export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature.js";
32
+ export { SCAFFOLD_STARTERS } from "./scaffold-blank-starter.js";
32
33
  export { checkDeployDrift, renderDeployFiles, scaffoldDeploy } from "./scaffold-deploy.js";
33
34
  export { scaffoldFeature } from "./scaffold-feature.js";
34
35
  export { setupTestStackFromFeatures, } from "./setup-test-stack-from-features.js";
@@ -1,13 +1,14 @@
1
1
  export declare const FRAMEWORK_AGENTS_IMPORT = "@node_modules/@cosmicdrift/kumiko-framework/AGENTS.md";
2
2
  export declare function renderMountedFeatureList(features: ReadonlyArray<{
3
3
  readonly name: string;
4
- }> | undefined): string;
4
+ }> | undefined, starter?: "demo" | "blank"): string;
5
5
  export type AgentsMdOptions = {
6
6
  readonly productName: string;
7
7
  readonly features: ReadonlyArray<{
8
8
  readonly name: string;
9
9
  }> | undefined;
10
10
  readonly scriptNames: readonly string[];
11
+ readonly starter?: "demo" | "blank";
11
12
  };
12
13
  export declare function renderAgentsMd(options: AgentsMdOptions): string;
13
14
  export declare function renderClaudeMd(): string;
@@ -20,11 +20,11 @@ const SCRIPT_DESCRIPTIONS = {
20
20
  };
21
21
  const DEMO_TASKS_FEATURE_LINE = "- `tasks` (demo — list + edit screens)";
22
22
  const FOUNDATION_FEATURE_LINES = ["- `secrets` (foundation)", "- `sessions` (foundation)"];
23
- export function renderMountedFeatureList(features) {
23
+ export function renderMountedFeatureList(features, starter = "demo") {
24
24
  const lines = features && features.length > 0
25
25
  ? features.map((f) => `- \`${f.name}\``)
26
26
  : FOUNDATION_FEATURE_LINES;
27
- return [...lines, DEMO_TASKS_FEATURE_LINE].join("\n");
27
+ return (starter === "demo" ? [...lines, DEMO_TASKS_FEATURE_LINE] : lines).join("\n");
28
28
  }
29
29
  function renderCommandList(scriptNames) {
30
30
  return scriptNames
@@ -42,7 +42,7 @@ function agentsTemplatePath() {
42
42
  export function renderAgentsMd(options) {
43
43
  return render(readFileSync(agentsTemplatePath(), "utf-8"), {
44
44
  productName: options.productName,
45
- featureList: renderMountedFeatureList(options.features),
45
+ featureList: renderMountedFeatureList(options.features, options.starter),
46
46
  commands: renderCommandList(options.scriptNames),
47
47
  }, {});
48
48
  }
@@ -1,3 +1,5 @@
1
+ import { type ScaffoldStarter } from "./scaffold-blank-starter.ts";
2
+ export { SCAFFOLD_STARTERS, type ScaffoldStarter } from "./scaffold-blank-starter.ts";
1
3
  export type ScaffoldFeatureEntry = {
2
4
  readonly name: string;
3
5
  readonly importPath: string;
@@ -15,6 +17,7 @@ export type ScaffoldTestSetup = {
15
17
  export type ScaffoldTestSetupFactory = (input: {
16
18
  readonly appName: string;
17
19
  readonly frameworkVersion: string;
20
+ readonly starter?: ScaffoldStarter;
18
21
  }) => ScaffoldTestSetup;
19
22
  export declare const SCAFFOLD_BIOME_VERSION = "2.6.4";
20
23
  export type ScaffoldAppOptions = {
@@ -40,6 +43,9 @@ export type ScaffoldAppOptions = {
40
43
  readonly testSetup?: ScaffoldTestSetupFactory;
41
44
  /** Infra tag written to `package.json#kumiko.deploy.infraRef`. Default: CURRENT_INFRA_REF. */
42
45
  readonly infraRef?: string;
46
+ /** "demo" (default): demo tasks feature, admin seeded from DEMO_ADMIN_PASSWORD.
47
+ * "blank": no demo; the creator is seeded from KUMIKO_BOOTSTRAP_ADMIN_EMAIL without a password. */
48
+ readonly starter?: ScaffoldStarter;
43
49
  };
44
50
  export type ScaffoldAppResult = {
45
51
  readonly destination: string;
@@ -28,7 +28,10 @@ import { resolveFrameworkVersion } from "./framework-version.js";
28
28
  import { isKebabSegment } from "./kebab.js";
29
29
  import { CURRENT_INFRA_REF, PREPARE_SCRIPT, resolveManagedAppFacts, syncManagedFiles, } from "./managed-files/index.js";
30
30
  import { renderAgentsMd, renderClaudeMd, renderMountedFeatureList } from "./scaffold-agent-docs.js";
31
+ import { BLANK_ENV_EXAMPLE_ADMIN_BLOCK, BLANK_STARTER_DELIVERY_FEATURE, blankReadmeCopy, renderBlankEnvSchema, renderBlankMain, renderBlankServer, } from "./scaffold-blank-starter.js";
31
32
  import { createDemoTasksFeature, renderDemoSeedFile, renderDemoTasksFeatureFile, renderDemoTasksI18n, renderDemoTasksIndex, renderDemoTasksWebIndex, } from "./scaffold-demo-tasks.js";
33
+ import { deriveTenantId, MAX_LINE_WIDTH, renderAdminAuthBlock, renderDefaultAppShellElement, renderSortedImports, } from "./scaffold-render-helpers.js";
34
+ export { SCAFFOLD_STARTERS } from "./scaffold-blank-starter.js";
32
35
  // Exact pin (no caret): the generated files are formatted to this Biome's output,
33
36
  // and a scaffold-lint test keeps it equal to the monorepo's own Biome version.
34
37
  export const SCAFFOLD_BIOME_VERSION = "2.6.4";
@@ -39,7 +42,30 @@ dist-server/
39
42
  test-results/
40
43
  playwright-report/
41
44
  `;
42
- export async function scaffoldApp(options) {
45
+ function writeDemoTasksFiles(destination, files) {
46
+ mkdirSync(join(destination, "src", "features", "tasks", "web"), { recursive: true });
47
+ write(join(destination, "src", "features", "tasks", "feature.ts"), renderDemoTasksFeatureFile());
48
+ files.push("src/features/tasks/feature.ts");
49
+ write(join(destination, "src", "features", "tasks", "i18n.ts"), renderDemoTasksI18n());
50
+ files.push("src/features/tasks/i18n.ts");
51
+ write(join(destination, "src", "features", "tasks", "index.ts"), renderDemoTasksIndex());
52
+ files.push("src/features/tasks/index.ts");
53
+ write(join(destination, "src", "features", "tasks", "web", "index.ts"), renderDemoTasksWebIndex());
54
+ files.push("src/features/tasks/web/index.ts");
55
+ write(join(destination, "src", "seed.ts"), renderDemoSeedFile());
56
+ files.push("src/seed.ts");
57
+ }
58
+ function writeBinFiles(destination, appName, isBlank, files) {
59
+ write(join(destination, "bin", "main.ts"), isBlank ? renderBlankMain(appName) : renderMain(appName));
60
+ files.push("bin/main.ts");
61
+ write(join(destination, "bin", "server.ts"), isBlank ? renderBlankServer(appName) : renderServer(appName));
62
+ files.push("bin/server.ts");
63
+ write(join(destination, "bin", "env-schema.ts"), isBlank ? renderBlankEnvSchema() : renderEnvSchema(appName));
64
+ files.push("bin/env-schema.ts");
65
+ write(join(destination, "bin", "kumiko.ts"), renderBinKumiko());
66
+ files.push("bin/kumiko.ts");
67
+ }
68
+ function resolveScaffoldTarget(options) {
43
69
  if (!isKebabSegment(options.name)) {
44
70
  throw new Error(`scaffoldApp: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`);
45
71
  }
@@ -52,7 +78,17 @@ export async function scaffoldApp(options) {
52
78
  if (version === undefined) {
53
79
  throw new Error("scaffoldApp: cannot resolve the framework version; pass frameworkVersion explicitly");
54
80
  }
55
- const testSetup = options.testSetup?.({ appName: options.name, frameworkVersion: version });
81
+ return { destination, version };
82
+ }
83
+ export async function scaffoldApp(options) {
84
+ const { destination, version } = resolveScaffoldTarget(options);
85
+ const starter = options.starter ?? "demo";
86
+ const isBlank = starter === "blank";
87
+ const testSetup = options.testSetup?.({
88
+ appName: options.name,
89
+ frameworkVersion: version,
90
+ ...(isBlank && { starter }),
91
+ });
56
92
  mkdirSync(join(destination, "bin"), { recursive: true });
57
93
  mkdirSync(join(destination, "src"), { recursive: true });
58
94
  mkdirSync(join(destination, "kumiko"), { recursive: true });
@@ -63,48 +99,30 @@ export async function scaffoldApp(options) {
63
99
  files.push("kumiko.json");
64
100
  write(join(destination, ".gitignore"), SCAFFOLD_GITIGNORE);
65
101
  files.push(".gitignore");
66
- write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features));
102
+ write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features, starter));
67
103
  files.push("src/run-config.ts");
68
- mkdirSync(join(destination, "src", "features", "tasks", "web"), { recursive: true });
69
- write(join(destination, "src", "features", "tasks", "feature.ts"), renderDemoTasksFeatureFile());
70
- files.push("src/features/tasks/feature.ts");
71
- write(join(destination, "src", "features", "tasks", "i18n.ts"), renderDemoTasksI18n());
72
- files.push("src/features/tasks/i18n.ts");
73
- write(join(destination, "src", "features", "tasks", "index.ts"), renderDemoTasksIndex());
74
- files.push("src/features/tasks/index.ts");
75
- write(join(destination, "src", "features", "tasks", "web", "index.ts"), renderDemoTasksWebIndex());
76
- files.push("src/features/tasks/web/index.ts");
77
- write(join(destination, "src", "seed.ts"), renderDemoSeedFile());
78
- files.push("src/seed.ts");
104
+ if (!isBlank)
105
+ writeDemoTasksFiles(destination, files);
79
106
  write(join(destination, APP_CONFIG_FILE), renderAppConfig());
80
107
  files.push(APP_CONFIG_FILE);
81
- write(join(destination, "bin", "main.ts"), renderMain(options.name));
82
- files.push("bin/main.ts");
83
- write(join(destination, "bin", "server.ts"), renderServer(options.name));
84
- files.push("bin/server.ts");
85
- write(join(destination, "bin", "env-schema.ts"), renderEnvSchema(options.name));
86
- files.push("bin/env-schema.ts");
87
- write(join(destination, "bin", "kumiko.ts"), renderBinKumiko());
88
- files.push("bin/kumiko.ts");
89
- write(join(destination, "src", "client.tsx"), renderClient(options.name));
108
+ writeBinFiles(destination, options.name, isBlank, files);
109
+ write(join(destination, "src", "client.tsx"), renderClient(options.name, starter));
90
110
  files.push("src/client.tsx");
91
111
  write(join(destination, "src", "styles.css"), renderStylesCss());
92
112
  files.push("src/styles.css");
93
113
  mkdirSync(join(destination, "public"), { recursive: true });
94
114
  write(join(destination, "public", "index.html"), renderIndexHtml(options.name));
95
115
  files.push("public/index.html");
96
- write(join(destination, ".env.example"), renderEnvExample(options.name));
116
+ write(join(destination, ".env.example"), renderEnvExample(options.name, starter));
97
117
  files.push(".env.example");
98
118
  write(join(destination, "docker-compose.yml"), renderDockerCompose());
99
119
  files.push("docker-compose.yml");
100
- await writeInitMigration(destination, options.features);
120
+ await writeInitMigration(destination, options.features, starter);
101
121
  files.push("kumiko/migrations/0001_init.sql", "kumiko/migrations/.snapshot.json");
102
- if (testSetup !== undefined) {
103
- for (const [rel, content] of Object.entries(testSetup.files)) {
104
- mkdirSync(dirname(join(destination, rel)), { recursive: true });
105
- write(join(destination, rel), content);
106
- files.push(rel);
107
- }
122
+ for (const [rel, content] of Object.entries(testSetup?.files ?? {})) {
123
+ mkdirSync(dirname(join(destination, rel)), { recursive: true });
124
+ write(join(destination, rel), content);
125
+ files.push(rel);
108
126
  }
109
127
  const managedFacts = resolveManagedAppFacts({
110
128
  appRoot: destination,
@@ -115,11 +133,12 @@ export async function scaffoldApp(options) {
115
133
  if (action.action === "create")
116
134
  files.push(action.path);
117
135
  }
118
- write(join(destination, "README.md"), `${renderReadme(options.name, options.features)}${testSetup === undefined ? "" : `\n${testSetup.rulesMarkdown}`}`);
136
+ write(join(destination, "README.md"), `${renderReadme(options.name, options.features, starter)}${testSetup === undefined ? "" : `\n${testSetup.rulesMarkdown}`}`);
119
137
  files.push("README.md");
120
138
  write(join(destination, "AGENTS.md"), renderAgentsMd({
121
139
  productName: options.name,
122
140
  features: options.features,
141
+ starter,
123
142
  scriptNames: Object.keys(appScripts(testSetup)),
124
143
  }));
125
144
  files.push("AGENTS.md");
@@ -207,42 +226,7 @@ const FOUNDATION_FEATURES = [
207
226
  // scaffolded `bun dev`; filtering also protects direct scaffoldApp() calls that
208
227
  // pass all bundled-feature entries, keeping these four out of run-config.ts.
209
228
  const COMPOSE_AUTO_MOUNTED_NAMES = new Set(["config", "user", "tenant", "auth-email-password"]);
210
- const MAX_LINE_WIDTH = 100;
211
- // Mirrors Biome's import sort so generated files pass `biome check` untouched:
212
- // packages alphabetically by specifier, relative paths last.
213
- function compareImportKeys(a, b) {
214
- const lowerA = a.replace(/^type /, "").toLowerCase();
215
- const lowerB = b.replace(/^type /, "").toLowerCase();
216
- if (lowerA !== lowerB)
217
- return lowerA < lowerB ? -1 : 1;
218
- return a < b ? -1 : a > b ? 1 : 0;
219
- }
220
- function isRelativeSpecifier(specifier) {
221
- return specifier.startsWith("./") || specifier.startsWith("../");
222
- }
223
- function renderImportLine({ specifier, names, typeOnly, defaultName }) {
224
- if (defaultName !== undefined)
225
- return `import ${defaultName} from "${specifier}";`;
226
- const sorted = [...names].sort(compareImportKeys);
227
- const keyword = typeOnly === true ? "import type" : "import";
228
- const oneLine = `${keyword} { ${sorted.join(", ")} } from "${specifier}";`;
229
- if (sorted.length === 1 || oneLine.length <= MAX_LINE_WIDTH)
230
- return oneLine;
231
- return `${keyword} {\n${sorted.map((n) => ` ${n},`).join("\n")}\n} from "${specifier}";`;
232
- }
233
- function renderSortedImports(imports) {
234
- return [...imports]
235
- .sort((a, b) => {
236
- const relativeA = isRelativeSpecifier(a.specifier);
237
- const relativeB = isRelativeSpecifier(b.specifier);
238
- if (relativeA !== relativeB)
239
- return relativeA ? 1 : -1;
240
- return compareImportKeys(a.specifier, b.specifier);
241
- })
242
- .map(renderImportLine)
243
- .join("\n");
244
- }
245
- function renderRunConfig(features) {
229
+ function effectiveScaffoldFeatures(features, starter) {
246
230
  // Fallback decided BEFORE filtering: if the caller passed features (even
247
231
  // if every one of them is an auto-mounted name), an all-filtered-out empty
248
232
  // result is the correct outcome — falling back to FOUNDATION_FEATURES here
@@ -250,6 +234,12 @@ function renderRunConfig(features) {
250
234
  // for.
251
235
  const base = features?.length ? features : FOUNDATION_FEATURES;
252
236
  const effective = base.filter((f) => !COMPOSE_AUTO_MOUNTED_NAMES.has(f.name));
237
+ const needsDelivery = starter === "blank" && !effective.some((f) => f.name === BLANK_STARTER_DELIVERY_FEATURE.name);
238
+ return needsDelivery ? [...effective, BLANK_STARTER_DELIVERY_FEATURE] : effective;
239
+ }
240
+ function renderRunConfig(features, starter) {
241
+ const effective = effectiveScaffoldFeatures(features, starter);
242
+ const hasTasks = starter === "demo";
253
243
  const grouped = new Map();
254
244
  for (const entry of effective) {
255
245
  const existing = grouped.get(entry.importPath) ?? [];
@@ -260,9 +250,13 @@ function renderRunConfig(features) {
260
250
  const imports = [
261
251
  ...[...grouped].map(([specifier, names]) => ({ specifier, names })),
262
252
  { specifier: "@cosmicdrift/kumiko-locale-de", names: ["localeDe"] },
263
- { specifier: "./features/tasks", names: ["tasksFeature"] },
253
+ ...(hasTasks ? [{ specifier: "./features/tasks", names: ["tasksFeature"] }] : []),
254
+ ];
255
+ const callExprs = [
256
+ ...effective.map((f) => f.callExpression),
257
+ "localeDe()",
258
+ ...(hasTasks ? ["tasksFeature"] : []),
264
259
  ];
265
- const callExprs = [...effective.map((f) => f.callExpression), "localeDe()", "tasksFeature"];
266
260
  const oneLineFeatures = `export const APP_FEATURES = [${callExprs.join(", ")}] as const;`;
267
261
  const appFeatures = oneLineFeatures.length <= MAX_LINE_WIDTH
268
262
  ? oneLineFeatures
@@ -283,23 +277,6 @@ ${appFeatures}
283
277
  export const HAS_AUTH = true;
284
278
  `;
285
279
  }
286
- function renderAdminAuthBlock(appName, extraAdminLines) {
287
- const passwordAndExtras = extraAdminLines.map((l) => ` ${l}`).join("\n");
288
- return ` auth: {
289
- admin: {
290
- email: "admin@${appName}.local",
291
- ${passwordAndExtras}
292
- memberships: [
293
- {
294
- tenantId: DEFAULT_TENANT_ID,
295
- tenantKey: "${appName}",
296
- tenantName: "${appName}",
297
- roles: ["TenantAdmin"],
298
- },
299
- ],
300
- },
301
- },`;
302
- }
303
280
  // Ports match runDevApp's default and kumiko-testing's SCAFFOLD_E2E_PORT.
304
281
  const SCAFFOLD_DEV_PORT = 4173;
305
282
  const SCAFFOLD_E2E_PORT = 4174;
@@ -442,42 +419,15 @@ ${authBlock}
442
419
  });
443
420
  `;
444
421
  }
445
- // Tiers mirror how Biome wraps the JSX as the name grows. The user spread
446
- // is part of every tier so the line-width math covers it.
447
- function renderDefaultAppShellElement(appName) {
448
- const spanOpen = '<span className="font-semibold tracking-tight">';
449
- const userSpread = "{...(user !== undefined && { user })}";
450
- // html-ok: appName is kebab-validated (a-z/0-9/-); this is generated .tsx source, not runtime HTML.
451
- const brandInline = `${spanOpen}${appName}</span>`;
452
- const singleLine = ` <DefaultAppShell brand={${brandInline}} schema={schema} ${userSpread}>`;
453
- if (singleLine.length <= MAX_LINE_WIDTH)
454
- return singleLine;
455
- const brandAttrInline = ` brand={${brandInline}}`;
456
- const brandAttribute = brandAttrInline.length <= MAX_LINE_WIDTH
457
- ? brandAttrInline
458
- : `${[
459
- " brand={",
460
- ...(` ${brandInline}`.length <= MAX_LINE_WIDTH
461
- ? [` ${brandInline}`]
462
- : [` ${spanOpen}`, ` ${appName}`, " </span>"]),
463
- " }",
464
- ].join("\n")}`;
465
- return [
466
- " <DefaultAppShell",
467
- brandAttribute,
468
- " schema={schema}",
469
- ` ${userSpread}`,
470
- " >",
471
- ].join("\n");
472
- }
473
- function renderClient(appName) {
422
+ function renderClient(appName, starter) {
423
+ const hasTasks = starter === "demo";
474
424
  const imports = renderSortedImports([
475
425
  {
476
426
  specifier: "@cosmicdrift/kumiko-bundled-features/auth-email-password/web",
477
427
  names: ["emailPasswordClient", "useShellUser"],
478
428
  },
479
429
  { specifier: "@cosmicdrift/kumiko-locale-de/web", names: ["localeDeClient"] },
480
- { specifier: "./features/tasks/web", names: ["tasksClient"] },
430
+ ...(hasTasks ? [{ specifier: "./features/tasks/web", names: ["tasksClient"] }] : []),
481
431
  {
482
432
  specifier: "@cosmicdrift/kumiko-renderer/web",
483
433
  names: ["type AppSchema", "createKumikoApp", "DefaultAppShell"],
@@ -511,12 +461,19 @@ ${renderDefaultAppShellElement(appName)}
511
461
 
512
462
  createKumikoApp({
513
463
  shell: AppShell,
514
- clientFeatures: [localeDeClient(), emailPasswordClient(), tasksClient],
464
+ clientFeatures: [localeDeClient(), emailPasswordClient()${hasTasks ? ", tasksClient" : ""}],
515
465
  });
516
466
  `;
517
467
  }
518
- function renderEnvExample(appName) {
468
+ function renderEnvExample(appName, starter) {
519
469
  const devDb = `${appName.replace(/-/g, "_")}_dev`;
470
+ const adminBlock = starter === "blank"
471
+ ? BLANK_ENV_EXAMPLE_ADMIN_BLOCK
472
+ : `# DEMO_ADMIN_PASSWORD: password of the admin seeded on first prod boot
473
+ # (admin@${appName}.local). Required by \`bun run boot\` and \`bun run start\`,
474
+ # min 12 chars. Generate with: openssl rand -hex 24
475
+ DEMO_ADMIN_PASSWORD=
476
+ `;
520
477
  return `# bun dev (runDevApp → setupTestStack) needs TEST_DATABASE_URL.
521
478
  # Production (bun bin/main.ts → runProdApp) needs DATABASE_URL.
522
479
  # Both default to the same local Postgres — runDevApp creates its own
@@ -536,11 +493,7 @@ JWT_SECRET=change-me-min-32-chars-change-me-min-32
536
493
  # Generate with: openssl rand -base64 32
537
494
  KUMIKO_SECRETS_MASTER_KEY_V1=
538
495
 
539
- # DEMO_ADMIN_PASSWORD: password of the admin seeded on first prod boot
540
- # (admin@${appName}.local). Required by \`bun run boot\` and \`bun run start\`,
541
- # min 12 chars. Generate with: openssl rand -hex 24
542
- DEMO_ADMIN_PASSWORD=
543
-
496
+ ${adminBlock}
544
497
  # Subject-keys KMS for PII fields (user, tenant-invitation, fileRef). All-or-none.
545
498
  # Unset → local boot with plaintext PII + warning; NODE_ENV=production requires
546
499
  # the trio (requireKmsWiring). Generate PLATFORM_KEK / KUMIKO_BLIND_INDEX_KEY
@@ -590,20 +543,28 @@ volumes:
590
543
  kumiko-pg:
591
544
  `;
592
545
  }
593
- function renderReadme(appName, features) {
594
- const featureList = renderMountedFeatureList(features);
546
+ function demoReadmeCopy(appName) {
547
+ return {
548
+ intro: "Scaffolded by `kumiko new app`. Includes a demo **tasks** feature with list +\nedit screens, sidebar nav, and seeded rows — `bun dev` shows a working admin UI\nafter login. Add more features via `bunx @cosmicdrift/kumiko-cli add feature <name>`.",
549
+ runConfigNote: "Edit `src/run-config.ts` to add bundled features. The demo lives in\n`src/features/tasks/`.",
550
+ devLogin: `The welcome banner prints the URL (default \`http://localhost:4173\`) and admin\nlogin. Sign in as \`admin@${appName}.local\` / \`changeme\`, then open **Tasks**\nin the sidebar — demo rows are pre-seeded.`,
551
+ bootEnvNote: "a missing one exits 0 without validating the wiring, so set\n`DEMO_ADMIN_PASSWORD` (min 12 chars, `openssl rand -hex 24`) in `.env`.",
552
+ prodSeed: `The first prod boot seeds \`admin@${appName}.local\` with the password from\n\`DEMO_ADMIN_PASSWORD\` (required, min 12 chars). Log in with that.`,
553
+ demoArchitectureLines: "- `src/features/tasks/` — demo feature (entity + handlers + screens + nav).\n- `src/seed.ts` — dev seed for demo tasks (`bun dev` only).\n",
554
+ };
555
+ }
556
+ function renderReadme(appName, features, starter) {
557
+ const copy = starter === "blank" ? blankReadmeCopy(appName) : demoReadmeCopy(appName);
558
+ const featureList = renderMountedFeatureList(features, starter);
595
559
  return `# ${appName}
596
560
 
597
- Scaffolded by \`kumiko new app\`. Includes a demo **tasks** feature with list +
598
- edit screens, sidebar nav, and seeded rows — \`bun dev\` shows a working admin UI
599
- after login. Add more features via \`bunx @cosmicdrift/kumiko-cli add feature <name>\`.
561
+ ${copy.intro}
600
562
 
601
563
  ## Mounted features
602
564
 
603
565
  ${featureList}
604
566
 
605
- Edit \`src/run-config.ts\` to add bundled features. The demo lives in
606
- \`src/features/tasks/\`.
567
+ ${copy.runConfigNote}
607
568
 
608
569
  ## First run (browser)
609
570
 
@@ -615,9 +576,7 @@ docker compose up -d # local Postgres + Redis (skip if you already have them)
615
576
  bun dev
616
577
  \`\`\`
617
578
 
618
- The welcome banner prints the URL (default \`http://localhost:4173\`) and admin
619
- login. Sign in as \`admin@${appName}.local\` / \`changeme\`, then open **Tasks**
620
- in the sidebar — demo rows are pre-seeded.
579
+ ${copy.devLogin}
621
580
 
622
581
  ## Boot-only smoke (no DB needed)
623
582
 
@@ -628,8 +587,7 @@ bun run boot
628
587
  Runs \`KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts\` — validates feature composition
629
588
  + env schema, exits 0 without touching DB/Redis. Useful in CI. It prints every
630
589
  variable with its feature and status, never the value. An invalid value exits 1;
631
- a missing one exits 0 without validating the wiring, so set
632
- \`DEMO_ADMIN_PASSWORD\` (min 12 chars, \`openssl rand -hex 24\`) in \`.env\`.
590
+ ${copy.bootEnvNote}
633
591
 
634
592
  Without \`PLATFORM_KEK\` / \`SUBJECT_KEYS_DATABASE_URL\` / \`KUMIKO_BLIND_INDEX_KEY\`
635
593
  set (see \`.env.example\`), both \`bun run boot\` and \`bun run start\` boot with
@@ -644,8 +602,7 @@ bun run schema:apply # apply checked-in kumiko/migrations (needs DATABASE_URL)
644
602
  bun run start # runProdApp against dist/
645
603
  \`\`\`
646
604
 
647
- The first prod boot seeds \`admin@${appName}.local\` with the password from
648
- \`DEMO_ADMIN_PASSWORD\` (required, min 12 chars). Log in with that.
605
+ ${copy.prodSeed}
649
606
 
650
607
  After adding entities/features, regenerate migrations:
651
608
 
@@ -669,9 +626,7 @@ Files whose first line reads \`kumiko-managed\` (deploy/, .github/workflows/,
669
626
  ## Architecture
670
627
 
671
628
  - \`src/run-config.ts\` — single source of truth: which features your app mounts (\`APP_FEATURES\`, \`HAS_AUTH\`).
672
- - \`src/features/tasks/\` — demo feature (entity + handlers + screens + nav).
673
- - \`src/seed.ts\` — dev seed for demo tasks (\`bun dev\` only).
674
- - \`kumiko.config.ts\` — the composed feature list, client entries and ports the tooling reads.
629
+ ${copy.demoArchitectureLines}- \`kumiko.config.ts\` — the composed feature list, client entries and ports the tooling reads.
675
630
  - \`kumiko/schema.ts\` — generated (\`kumiko-managed\`): \`ENTITY_METAS\` from kumiko.config.ts for \`kumiko schema\`.
676
631
  - \`bin/server.ts\` — dev-server entry (\`bun dev\`).
677
632
  - \`bin/env-schema.ts\` — env contract of the production processes.
@@ -770,9 +725,8 @@ function renderBinKumiko() {
770
725
  "",
771
726
  ].join("\n");
772
727
  }
773
- async function instantiateScaffoldFeatures(features) {
774
- const base = features?.length ? features : FOUNDATION_FEATURES;
775
- const effective = base.filter((f) => !COMPOSE_AUTO_MOUNTED_NAMES.has(f.name));
728
+ async function instantiateScaffoldFeatures(features, starter) {
729
+ const effective = effectiveScaffoldFeatures(features, starter);
776
730
  const instances = [];
777
731
  for (const entry of effective) {
778
732
  const mod = (await import(__rewriteRelativeImportExtension(entry.importPath)));
@@ -791,11 +745,12 @@ async function instantiateScaffoldFeatures(features) {
791
745
  instances.push(exp);
792
746
  }
793
747
  }
794
- instances.push(createDemoTasksFeature());
748
+ if (starter === "demo")
749
+ instances.push(createDemoTasksFeature());
795
750
  return instances;
796
751
  }
797
- async function writeInitMigration(destination, features) {
798
- const instances = await instantiateScaffoldFeatures(features);
752
+ async function writeInitMigration(destination, features, starter) {
753
+ const instances = await instantiateScaffoldFeatures(features, starter);
799
754
  const composed = composeFeatures(instances, { includeBundled: true });
800
755
  const metas = collectTableMetas(composed);
801
756
  const result = generateMigration({
@@ -810,29 +765,3 @@ async function writeInitMigration(destination, features) {
810
765
  writeFileSync(join(migrationsDir, result.filename), result.sqlContent);
811
766
  writeSnapshotJson(join(migrationsDir, ".snapshot.json"), result.snapshot);
812
767
  }
813
- // Deterministic tenant-ID from app-name. Format: UUID-v4 with the
814
- // version-marker at the right spot. NOT cryptographically random —
815
- // just a stable per-app default the user can change later.
816
- function deriveTenantId(name) {
817
- let state = 2166136261;
818
- for (const ch of name) {
819
- state ^= ch.charCodeAt(0);
820
- state = Math.imul(state, 16777619) >>> 0;
821
- }
822
- // @wrapper-known semantic-alias
823
- const hex = (n, len) => n.toString(16).padStart(len, "0").slice(0, len);
824
- const a = hex(state, 8);
825
- state ^= state << 13;
826
- state >>>= 0;
827
- const b = hex(state, 4);
828
- state ^= state >>> 17;
829
- state >>>= 0;
830
- const c = `4${hex(state, 3)}`;
831
- state ^= state << 5;
832
- state >>>= 0;
833
- const d4 = (0x8 | (state & 0x3)).toString(16);
834
- const d = `${d4}${hex(state >>> 4, 3)}`;
835
- state = Math.imul(state, 16777619) >>> 0;
836
- const e = hex(state, 12);
837
- return `${a}-${b}-${c}-${d}-${e}`;
838
- }
@@ -0,0 +1,21 @@
1
+ export declare const SCAFFOLD_STARTERS: readonly ["demo", "blank"];
2
+ export type ScaffoldStarter = (typeof SCAFFOLD_STARTERS)[number];
3
+ export declare const BLANK_STARTER_DELIVERY_FEATURE: {
4
+ readonly name: "delivery";
5
+ readonly importPath: "@cosmicdrift/kumiko-bundled-features/delivery";
6
+ readonly exportName: "createDeliveryFeature";
7
+ readonly callExpression: "createDeliveryFeature()";
8
+ };
9
+ export declare function renderBlankEnvSchema(): string;
10
+ export declare function renderBlankMain(appName: string): string;
11
+ export declare function renderBlankServer(appName: string): string;
12
+ export declare const BLANK_ENV_EXAMPLE_ADMIN_BLOCK = "# KUMIKO_BOOTSTRAP_ADMIN_EMAIL: login of the person who created the app. The first prod\n# boot makes it the admin WITHOUT a password; set one via \"forgot password\". That needs\n# SMTP_HOST (+ SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM) and APP_BASE_URL (e.g.\n# https://app.example.com); without them the reset mail flow stays off.\n# KUMIKO_BOOTSTRAP_ADMIN_EMAIL=\n# SMTP_HOST=\n# APP_BASE_URL=\n";
13
+ export type ReadmeCopy = {
14
+ readonly intro: string;
15
+ readonly runConfigNote: string;
16
+ readonly devLogin: string;
17
+ readonly bootEnvNote: string;
18
+ readonly prodSeed: string;
19
+ readonly demoArchitectureLines: string;
20
+ };
21
+ export declare function blankReadmeCopy(appName: string): ReadmeCopy;
@@ -0,0 +1,142 @@
1
+ // The `blank` starter of scaffoldApp: no demo feature and no admin password in the env. The
2
+ // person who creates the app is seeded from KUMIKO_BOOTSTRAP_ADMIN_EMAIL and sets a password
3
+ // through "forgot password".
4
+ import { deriveTenantId, renderAdminAuthBlock, renderSortedImports, } from "./scaffold-render-helpers.js";
5
+ export const SCAFFOLD_STARTERS = ["demo", "blank"];
6
+ // Delivery is what the `mail` boot option sends through.
7
+ export const BLANK_STARTER_DELIVERY_FEATURE = {
8
+ name: "delivery",
9
+ importPath: "@cosmicdrift/kumiko-bundled-features/delivery",
10
+ exportName: "createDeliveryFeature",
11
+ callExpression: "createDeliveryFeature()",
12
+ };
13
+ export function renderBlankEnvSchema() {
14
+ const imports = renderSortedImports([
15
+ { specifier: "@cosmicdrift/kumiko-dev-server/env-schema", names: ["frameworkCoreEnvSchema"] },
16
+ { specifier: "@cosmicdrift/kumiko-framework/config", names: ["allAppFeatures"] },
17
+ { specifier: "@cosmicdrift/kumiko-framework/env", names: ["composeEnvSchema"] },
18
+ ]);
19
+ return `// Env contract of the production processes (bin/main.ts, bin/kumiko.ts).
20
+ // Importing it must not read process.env; validation happens in runProdApp.
21
+ // KUMIKO_BOOTSTRAP_ADMIN_EMAIL joins the schema through the seeds option in bin/main.ts.
22
+
23
+ ${imports}
24
+ import appConfig from "../kumiko.config";
25
+
26
+ export const envSchema = composeEnvSchema({
27
+ core: frameworkCoreEnvSchema,
28
+ features: allAppFeatures(appConfig),
29
+ });
30
+ `;
31
+ }
32
+ export function renderBlankMain(appName) {
33
+ const imports = renderSortedImports([
34
+ { specifier: "@cosmicdrift/kumiko-server-runtime", names: ["runProdApp"] },
35
+ { specifier: "@cosmicdrift/kumiko-framework/engine", names: ["TenantId"], typeOnly: true },
36
+ {
37
+ specifier: "@cosmicdrift/kumiko-framework/crypto",
38
+ names: ["requireKmsWiring", "resolveKmsWiring"],
39
+ },
40
+ {
41
+ specifier: "@cosmicdrift/kumiko-bundled-features/auth-email-password",
42
+ names: ["DEFAULT_AUTH_PATHS"],
43
+ },
44
+ { specifier: "../src/run-config", names: ["APP_FEATURES"] },
45
+ { specifier: "../kumiko.config", names: [], defaultName: "appConfig" },
46
+ { specifier: "./env-schema", names: ["envSchema"] },
47
+ ]);
48
+ return `// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
49
+ // composeFeatures + validateBoot + createRegistry without DB/Redis-connect
50
+ // (see @cosmicdrift/kumiko-server-runtime runProdApp).
51
+
52
+ ${imports}
53
+
54
+ const DEFAULT_TENANT_ID = "${deriveTenantId(appName)}" as TenantId;
55
+
56
+ const kmsOpts = {
57
+ logPrefix: "[${appName}]",
58
+ plaintextReason:
59
+ "no PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY set — see .env.example",
60
+ } as const;
61
+
62
+ const kmsWiring =
63
+ process.env["NODE_ENV"] === "production"
64
+ ? requireKmsWiring(process.env, kmsOpts)
65
+ : resolveKmsWiring(process.env, kmsOpts);
66
+
67
+ // The bootstrap admin has no password; password reset is the only way it gets one. Without SMTP
68
+ // and APP_BASE_URL (local boot) the mail flow stays off and the app still boots.
69
+ const appBaseUrl = process.env["APP_BASE_URL"];
70
+ const mailAppUrl = process.env["SMTP_HOST"] && appBaseUrl ? appBaseUrl : undefined;
71
+
72
+ await runProdApp({
73
+ features: APP_FEATURES,
74
+ ...(appConfig.validateBootOptions && { validateBootOptions: appConfig.validateBootOptions }),
75
+ ...(appConfig.rowLevelSecurity && { rowLevelSecurity: appConfig.rowLevelSecurity }),
76
+ envSchema,
77
+ staticDir: "./dist",
78
+ ...kmsWiring,
79
+ ...(mailAppUrl !== undefined && { mail: { transport: "smtp-env" } }),
80
+ auth: {
81
+ ...(mailAppUrl !== undefined && {
82
+ passwordReset: { appUrl: \`\${mailAppUrl}\${DEFAULT_AUTH_PATHS.resetPassword}\` },
83
+ }),
84
+ },
85
+ seeds: {
86
+ bootstrapAdminFromEnv: {
87
+ tenant: { id: DEFAULT_TENANT_ID, key: "${appName}", name: "${appName}" },
88
+ },
89
+ },
90
+ });
91
+ `;
92
+ }
93
+ export function renderBlankServer(appName) {
94
+ const imports = renderSortedImports([
95
+ { specifier: "@cosmicdrift/kumiko-dev-server", names: ["runDevApp"] },
96
+ { specifier: "@cosmicdrift/kumiko-framework/engine", names: ["TenantId"], typeOnly: true },
97
+ { specifier: "../src/run-config", names: ["APP_FEATURES"] },
98
+ { specifier: "../kumiko.config", names: [], defaultName: "appConfig" },
99
+ ]);
100
+ const authBlock = renderAdminAuthBlock(appName, [
101
+ 'password: "changeme",',
102
+ 'displayName: "Admin",',
103
+ ]);
104
+ return `// Dev bootstrap. \`bun --watch bin/server.ts\` (see package.json scripts.dev)
105
+ // starts a full-featured dev server with auto-reload on code changes.
106
+ // setupTestStack creates missing entity tables automatically — a new
107
+ // r.entity(...) in a feature becomes a CREATE TABLE on the next reboot,
108
+ // no manual \`kumiko schema apply\` needed (that's prod-only).
109
+ // Persistent DB via KUMIKO_DEV_DB_NAME (.env) so admin + data survive reboots.
110
+ // The local admin below is dev-only: no mail runs locally, so there is no password reset.
111
+
112
+ ${imports}
113
+
114
+ const DEFAULT_TENANT_ID = "${deriveTenantId(appName)}" as TenantId;
115
+
116
+ await runDevApp({
117
+ features: APP_FEATURES,
118
+ ...(appConfig.validateBootOptions && { validateBootOptions: appConfig.validateBootOptions }),
119
+ welcomeBanner: true,
120
+ clientEntry: "./src/client.tsx",
121
+ ${authBlock}
122
+ });
123
+ `;
124
+ }
125
+ export const BLANK_ENV_EXAMPLE_ADMIN_BLOCK = `# KUMIKO_BOOTSTRAP_ADMIN_EMAIL: login of the person who created the app. The first prod
126
+ # boot makes it the admin WITHOUT a password; set one via "forgot password". That needs
127
+ # SMTP_HOST (+ SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM) and APP_BASE_URL (e.g.
128
+ # https://app.example.com); without them the reset mail flow stays off.
129
+ # KUMIKO_BOOTSTRAP_ADMIN_EMAIL=
130
+ # SMTP_HOST=
131
+ # APP_BASE_URL=
132
+ `;
133
+ export function blankReadmeCopy(appName) {
134
+ return {
135
+ intro: "Scaffolded by `kumiko new app --blank`. An empty app with login, tenants and sessions.\nAdd features via `bunx @cosmicdrift/kumiko-cli add feature <name>`.",
136
+ runConfigNote: "Edit `src/run-config.ts` to add bundled features.",
137
+ devLogin: `The welcome banner prints the URL (default \`http://localhost:4173\`) and the local\ndev login \`admin@${appName}.local\` / \`changeme\` (dev server only).`,
138
+ bootEnvNote: "a missing one exits 0 without validating the wiring.",
139
+ prodSeed: 'The first prod boot makes `KUMIKO_BOOTSTRAP_ADMIN_EMAIL` the admin of the app, without a\npassword. Open the app, choose "forgot password" and set one through the mail link. That\nneeds `SMTP_HOST` (+ `SMTP_*`) and `APP_BASE_URL`; without them nobody can set a password.\nWhile the variable is unset, nobody is seeded.',
140
+ demoArchitectureLines: "",
141
+ };
142
+ }
@@ -0,0 +1,11 @@
1
+ export declare const MAX_LINE_WIDTH = 100;
2
+ export type ImportSpec = {
3
+ readonly specifier: string;
4
+ readonly names: readonly string[];
5
+ readonly typeOnly?: boolean;
6
+ readonly defaultName?: string;
7
+ };
8
+ export declare function renderSortedImports(imports: readonly ImportSpec[]): string;
9
+ export declare function renderDefaultAppShellElement(appName: string): string;
10
+ export declare function deriveTenantId(name: string): string;
11
+ export declare function renderAdminAuthBlock(appName: string, extraAdminLines: readonly string[]): string;
@@ -0,0 +1,108 @@
1
+ // Text-rendering helpers shared by the scaffold renderers: import blocks formatted like Biome's
2
+ // output and the per-app derived tenant id.
3
+ export const MAX_LINE_WIDTH = 100;
4
+ // Mirrors Biome's import sort so generated files pass `biome check` untouched:
5
+ // packages alphabetically by specifier, relative paths last.
6
+ function compareImportKeys(a, b) {
7
+ const lowerA = a.replace(/^type /, "").toLowerCase();
8
+ const lowerB = b.replace(/^type /, "").toLowerCase();
9
+ if (lowerA !== lowerB)
10
+ return lowerA < lowerB ? -1 : 1;
11
+ return a < b ? -1 : a > b ? 1 : 0;
12
+ }
13
+ function isRelativeSpecifier(specifier) {
14
+ return specifier.startsWith("./") || specifier.startsWith("../");
15
+ }
16
+ function renderImportLine({ specifier, names, typeOnly, defaultName }) {
17
+ if (defaultName !== undefined)
18
+ return `import ${defaultName} from "${specifier}";`;
19
+ const sorted = [...names].sort(compareImportKeys);
20
+ const keyword = typeOnly === true ? "import type" : "import";
21
+ const oneLine = `${keyword} { ${sorted.join(", ")} } from "${specifier}";`;
22
+ if (sorted.length === 1 || oneLine.length <= MAX_LINE_WIDTH)
23
+ return oneLine;
24
+ return `${keyword} {\n${sorted.map((n) => ` ${n},`).join("\n")}\n} from "${specifier}";`;
25
+ }
26
+ export function renderSortedImports(imports) {
27
+ return [...imports]
28
+ .sort((a, b) => {
29
+ const relativeA = isRelativeSpecifier(a.specifier);
30
+ const relativeB = isRelativeSpecifier(b.specifier);
31
+ if (relativeA !== relativeB)
32
+ return relativeA ? 1 : -1;
33
+ return compareImportKeys(a.specifier, b.specifier);
34
+ })
35
+ .map(renderImportLine)
36
+ .join("\n");
37
+ }
38
+ // Tiers mirror how Biome wraps the JSX as the name grows. The user spread
39
+ // is part of every tier so the line-width math covers it.
40
+ export function renderDefaultAppShellElement(appName) {
41
+ const spanOpen = '<span className="font-semibold tracking-tight">';
42
+ const userSpread = "{...(user !== undefined && { user })}";
43
+ // html-ok: appName is kebab-validated (a-z/0-9/-); this is generated .tsx source, not runtime HTML.
44
+ const brandInline = `${spanOpen}${appName}</span>`;
45
+ const singleLine = ` <DefaultAppShell brand={${brandInline}} schema={schema} ${userSpread}>`;
46
+ if (singleLine.length <= MAX_LINE_WIDTH)
47
+ return singleLine;
48
+ const brandAttrInline = ` brand={${brandInline}}`;
49
+ const brandAttribute = brandAttrInline.length <= MAX_LINE_WIDTH
50
+ ? brandAttrInline
51
+ : `${[
52
+ " brand={",
53
+ ...(` ${brandInline}`.length <= MAX_LINE_WIDTH
54
+ ? [` ${brandInline}`]
55
+ : [` ${spanOpen}`, ` ${appName}`, " </span>"]),
56
+ " }",
57
+ ].join("\n")}`;
58
+ return [
59
+ " <DefaultAppShell",
60
+ brandAttribute,
61
+ " schema={schema}",
62
+ ` ${userSpread}`,
63
+ " >",
64
+ ].join("\n");
65
+ }
66
+ // Deterministic tenant-ID from app-name. Format: UUID-v4 with the
67
+ // version-marker at the right spot. NOT cryptographically random —
68
+ // just a stable per-app default the user can change later.
69
+ export function deriveTenantId(name) {
70
+ let state = 2166136261;
71
+ for (const ch of name) {
72
+ state ^= ch.charCodeAt(0);
73
+ state = Math.imul(state, 16777619) >>> 0;
74
+ }
75
+ // @wrapper-known semantic-alias
76
+ const hex = (n, len) => n.toString(16).padStart(len, "0").slice(0, len);
77
+ const a = hex(state, 8);
78
+ state ^= state << 13;
79
+ state >>>= 0;
80
+ const b = hex(state, 4);
81
+ state ^= state >>> 17;
82
+ state >>>= 0;
83
+ const c = `4${hex(state, 3)}`;
84
+ state ^= state << 5;
85
+ state >>>= 0;
86
+ const d4 = (0x8 | (state & 0x3)).toString(16);
87
+ const d = `${d4}${hex(state >>> 4, 3)}`;
88
+ state = Math.imul(state, 16777619) >>> 0;
89
+ const e = hex(state, 12);
90
+ return `${a}-${b}-${c}-${d}-${e}`;
91
+ }
92
+ export function renderAdminAuthBlock(appName, extraAdminLines) {
93
+ const passwordAndExtras = extraAdminLines.map((l) => ` ${l}`).join("\n");
94
+ return ` auth: {
95
+ admin: {
96
+ email: "admin@${appName}.local",
97
+ ${passwordAndExtras}
98
+ memberships: [
99
+ {
100
+ tenantId: DEFAULT_TENANT_ID,
101
+ tenantKey: "${appName}",
102
+ tenantName: "${appName}",
103
+ roles: ["TenantAdmin"],
104
+ },
105
+ ],
106
+ },
107
+ },`;
108
+ }
@@ -4,7 +4,7 @@ export type WelcomeBannerInput = {
4
4
  /** Login the seeded admin uses (from runDevApp options.auth.admin). */
5
5
  readonly admin?: {
6
6
  readonly email: string;
7
- readonly password: string;
7
+ readonly password?: string | undefined;
8
8
  };
9
9
  /** Hot-reload hint shown in the "add a feature" line. Defaults to
10
10
  * `src/features/`. */
@@ -10,7 +10,9 @@ export function renderWelcomeBanner(input) {
10
10
  lines.push(`✓ kumiko-app läuft`);
11
11
  lines.push(`→ Browser: ${input.url}`);
12
12
  if (input.admin) {
13
- lines.push(`→ Login als: ${input.admin.email} / ${input.admin.password}`);
13
+ lines.push(input.admin.password === undefined
14
+ ? `→ Login als: ${input.admin.email}`
15
+ : `→ Login als: ${input.admin.email} / ${input.admin.password}`);
14
16
  }
15
17
  lines.push(`→ Feature add: edit ${featuresDir}`);
16
18
  lines.push(`→ Docs: ${docsUrl}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.409.0",
3
+ "version": "0.411.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding. 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": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -77,15 +77,15 @@
77
77
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
78
78
  },
79
79
  "dependencies": {
80
- "@cosmicdrift/kumiko-bundled-features": "0.409.0",
81
- "@cosmicdrift/kumiko-feature-ast": "0.409.0",
82
- "@cosmicdrift/kumiko-framework": "0.409.0",
83
- "@cosmicdrift/kumiko-renderer": "0.409.0",
84
- "@cosmicdrift/kumiko-renderer-mail-html": "0.409.0",
85
- "@cosmicdrift/kumiko-guards": "0.409.0",
86
- "@cosmicdrift/kumiko-server-runtime": "0.409.0",
87
- "@cosmicdrift/kumiko-testing": "0.409.0",
88
- "@cosmicdrift/kumiko-types": "0.409.0",
80
+ "@cosmicdrift/kumiko-bundled-features": "0.411.0",
81
+ "@cosmicdrift/kumiko-feature-ast": "0.411.0",
82
+ "@cosmicdrift/kumiko-framework": "0.411.0",
83
+ "@cosmicdrift/kumiko-renderer": "0.411.0",
84
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.411.0",
85
+ "@cosmicdrift/kumiko-guards": "0.411.0",
86
+ "@cosmicdrift/kumiko-server-runtime": "0.411.0",
87
+ "@cosmicdrift/kumiko-testing": "0.411.0",
88
+ "@cosmicdrift/kumiko-types": "0.411.0",
89
89
  "ts-morph": "^28.0.0",
90
90
  "zod": "^4.6.7"
91
91
  },
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.411.0",
4
+ "type": "improvement",
5
+ "title": "scaffoldApp starter \"blank\"",
6
+ "detail": "`scaffoldApp({ starter: \"blank\" })` (`SCAFFOLD_STARTERS`, `ScaffoldStarter`) writes an app without the tasks demo, `src/seed.ts` and `DEMO_ADMIN_PASSWORD`. `bin/main.ts` seeds the creator through `seeds.bootstrapAdminFromEnv` and wires SMTP mail and password reset when `SMTP_HOST` and `APP_BASE_URL` are set; `delivery` is mounted. The welcome banner prints only the email when the dev admin has no password. The default starter `demo` is unchanged."
7
+ },
2
8
  {
3
9
  "version": "0.408.0",
4
10
  "type": "fix",