@cosmicdrift/kumiko-dev-server 0.72.0 → 0.74.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -50,8 +50,8 @@
50
50
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
51
51
  },
52
52
  "dependencies": {
53
- "@cosmicdrift/kumiko-bundled-features": "0.72.0",
54
- "@cosmicdrift/kumiko-framework": "0.72.0",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.74.0",
54
+ "@cosmicdrift/kumiko-framework": "0.74.0",
55
55
  "ts-morph": "^28.0.0"
56
56
  },
57
57
  "publishConfig": {
@@ -119,6 +119,43 @@ describe("scaffoldApp", () => {
119
119
  expect(() => scaffoldApp({ name: "existing", destination: dest })).toThrow(/already exists/);
120
120
  });
121
121
 
122
+ test("features-param: custom selection lands in run-config.ts imports + APP_FEATURES", () => {
123
+ const dest = join(tmp, "custom-features");
124
+ scaffoldApp({
125
+ name: "custom-features",
126
+ destination: dest,
127
+ features: [
128
+ {
129
+ name: "tenant",
130
+ importPath: "@cosmicdrift/kumiko-bundled-features/tenant",
131
+ exportName: "createTenantFeature",
132
+ callExpression: "createTenantFeature()",
133
+ },
134
+ {
135
+ name: "billing-foundation",
136
+ importPath: "@cosmicdrift/kumiko-bundled-features/billing-foundation",
137
+ exportName: "billingFoundationFeature",
138
+ callExpression: "billingFoundationFeature",
139
+ },
140
+ ],
141
+ });
142
+ const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
143
+ expect(cfg).toContain('from "@cosmicdrift/kumiko-bundled-features/tenant"');
144
+ expect(cfg).toContain('from "@cosmicdrift/kumiko-bundled-features/billing-foundation"');
145
+ expect(cfg).toContain("createTenantFeature()");
146
+ expect(cfg).toContain("billingFoundationFeature");
147
+ expect(cfg).not.toContain("createSecretsFeature");
148
+ expect(cfg).not.toContain("createSessionsFeature");
149
+ });
150
+
151
+ test("features-param: empty array falls back to foundation (backwards-compat)", () => {
152
+ const dest = join(tmp, "empty-features");
153
+ scaffoldApp({ name: "empty-features", destination: dest, features: [] });
154
+ const cfg = readFileSync(join(dest, "src/run-config.ts"), "utf-8");
155
+ expect(cfg).toContain("createSecretsFeature()");
156
+ expect(cfg).toContain("createSessionsFeature()");
157
+ });
158
+
122
159
  test("deterministic tenantId for same name (reproducible boots)", () => {
123
160
  const a = join(tmp, "a");
124
161
  const b = join(tmp, "b");
@@ -0,0 +1,42 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { renderWelcomeBanner } from "../welcome-banner";
3
+
4
+ describe("renderWelcomeBanner", () => {
5
+ test("includes URL + admin login + features dir + docs link", () => {
6
+ const banner = renderWelcomeBanner({
7
+ url: "http://localhost:3000",
8
+ admin: { email: "admin@demo.test", password: "changeme" },
9
+ });
10
+ expect(banner).toContain("http://localhost:3000");
11
+ expect(banner).toContain("admin@demo.test");
12
+ expect(banner).toContain("changeme");
13
+ expect(banner).toContain("src/features/");
14
+ expect(banner).toContain("docs.kumiko.rocks");
15
+ });
16
+
17
+ test("box rows are aligned (all the same printable width)", () => {
18
+ const banner = renderWelcomeBanner({
19
+ url: "http://localhost:3000",
20
+ admin: { email: "a@b.c", password: "x" },
21
+ });
22
+ const rows = banner.split("\n");
23
+ const widths = rows.map((r) => [...r].length);
24
+ expect(new Set(widths).size).toBe(1);
25
+ });
26
+
27
+ test("admin is optional (no-auth dev runs still get a banner)", () => {
28
+ const banner = renderWelcomeBanner({ url: "http://localhost:3000" });
29
+ expect(banner).toContain("http://localhost:3000");
30
+ expect(banner).not.toContain("Login als");
31
+ });
32
+
33
+ test("featuresDir + docsUrl overridable", () => {
34
+ const banner = renderWelcomeBanner({
35
+ url: "http://localhost:3000",
36
+ featuresDir: "app/modules/",
37
+ docsUrl: "https://internal.example.com/dev",
38
+ });
39
+ expect(banner).toContain("app/modules/");
40
+ expect(banner).toContain("internal.example.com/dev");
41
+ });
42
+ });
package/src/index.ts CHANGED
@@ -54,7 +54,11 @@ export type {
54
54
  SignupSetup,
55
55
  } from "./run-prod-app";
56
56
  export { runProdApp } from "./run-prod-app";
57
- export type { ScaffoldAppOptions, ScaffoldAppResult } from "./scaffold-app";
57
+ export type {
58
+ ScaffoldAppOptions,
59
+ ScaffoldAppResult,
60
+ ScaffoldFeatureEntry,
61
+ } from "./scaffold-app";
58
62
  export { scaffoldApp } from "./scaffold-app";
59
63
  export type {
60
64
  ScaffoldAppFeatureOptions,
@@ -69,3 +73,4 @@ export type {
69
73
  export { scaffoldDeploy } from "./scaffold-deploy";
70
74
  export type { ScaffoldFeatureOptions, ScaffoldFeatureResult } from "./scaffold-feature";
71
75
  export { scaffoldFeature } from "./scaffold-feature";
76
+ export { renderWelcomeBanner, type WelcomeBannerInput } from "./welcome-banner";
@@ -51,6 +51,7 @@ import {
51
51
  createKumikoServer,
52
52
  type KumikoServerHandle,
53
53
  } from "./create-kumiko-server";
54
+ import { renderWelcomeBanner } from "./welcome-banner";
54
55
 
55
56
  // Re-export der shared Auth-Setup-Types damit Apps nur einen Import-Pfad
56
57
  // brauchen. PasswordResetSetup / EmailVerificationSetup leben in
@@ -177,6 +178,13 @@ export type RunDevAppOptions = {
177
178
  * in einem seed-fn, weil die runtime stack.db braucht und die seed-
178
179
  * Funktionen nach setupTestStack laufen. */
179
180
  readonly effectiveFeatures?: CreateKumikoServerOptions["effectiveFeatures"];
181
+ /** Print a first-run banner after the server starts (URL, admin login,
182
+ * hot-reload hint, docs link). Default: off — apps that already log
183
+ * their own startup shouldn't get double-printed. The scaffold template
184
+ * (create-kumiko-app) flips this on so the first `bun dev` ends on
185
+ * something the user can click. Pass an object to override the
186
+ * features-dir hint or the docs URL. */
187
+ readonly welcomeBanner?: boolean | { readonly featuresDir?: string; readonly docsUrl?: string };
180
188
  };
181
189
 
182
190
  export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServerHandle> {
@@ -304,7 +312,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
304
312
  }
305
313
  : {};
306
314
 
307
- return createKumikoServer({
315
+ const handle = await createKumikoServer({
308
316
  features,
309
317
  ...(options.clientEntry !== undefined && { clientEntry: options.clientEntry }),
310
318
  ...(options.clientEntries !== undefined && { clientEntries: options.clientEntries }),
@@ -407,6 +415,26 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
407
415
  }
408
416
  },
409
417
  });
418
+
419
+ if (options.welcomeBanner) {
420
+ const overrides = typeof options.welcomeBanner === "object" ? options.welcomeBanner : {};
421
+ const port = handle.server?.port ?? options.port ?? 3000;
422
+ const banner = renderWelcomeBanner({
423
+ url: `http://localhost:${port}`,
424
+ ...(options.auth?.admin && {
425
+ admin: {
426
+ email: options.auth.admin.email,
427
+ password: options.auth.admin.password,
428
+ },
429
+ }),
430
+ ...(overrides.featuresDir !== undefined && { featuresDir: overrides.featuresDir }),
431
+ ...(overrides.docsUrl !== undefined && { docsUrl: overrides.docsUrl }),
432
+ });
433
+ // biome-ignore lint/suspicious/noConsole: boot-time UX print, opt-in via welcomeBanner.
434
+ console.log(`\n${banner}\n`);
435
+ }
436
+
437
+ return handle;
410
438
  }
411
439
 
412
440
  // Exported for the wiring-contract test (config-resolver-default.integration):
@@ -16,6 +16,18 @@ import { join, resolve } from "node:path";
16
16
  import { IndentationText, Project, VariableDeclarationKind } from "ts-morph";
17
17
  import { isKebabSegment } from "./kebab";
18
18
 
19
+ // Single bundled-feature entry the scaffolder mounts into run-config.ts.
20
+ // importPath is the from-spec ("@cosmicdrift/kumiko-bundled-features/files"),
21
+ // exportName the named import, callExpression the form that lands in the
22
+ // APP_FEATURES array literal — typically `${exportName}()` for factory-style
23
+ // features and just `${exportName}` for object-style ones (e.g. billingFoundationFeature).
24
+ export type ScaffoldFeatureEntry = {
25
+ readonly name: string;
26
+ readonly importPath: string;
27
+ readonly exportName: string;
28
+ readonly callExpression: string;
29
+ };
30
+
19
31
  export type ScaffoldAppOptions = {
20
32
  /** kebab-case app name (e.g. "my-shop"). Becomes package-name + folder. */
21
33
  readonly name: string;
@@ -28,6 +40,10 @@ export type ScaffoldAppOptions = {
28
40
  readonly cwd?: string;
29
41
  /** npm-version-pin for @cosmicdrift/* deps. Default "*" for latest. */
30
42
  readonly frameworkVersion?: string;
43
+ /** Bundled-features to mount in run-config.ts. Default: secrets + sessions
44
+ * (the historical foundation). create-kumiko-app passes the picker output
45
+ * here so the generated APP_FEATURES reflects the user's selection. */
46
+ readonly features?: ReadonlyArray<ScaffoldFeatureEntry>;
31
47
  };
32
48
 
33
49
  export type ScaffoldAppResult = {
@@ -58,7 +74,7 @@ export function scaffoldApp(options: ScaffoldAppOptions): ScaffoldAppResult {
58
74
  write(join(destination, "tsconfig.json"), renderTsconfig());
59
75
  files.push("tsconfig.json");
60
76
 
61
- write(join(destination, "src", "run-config.ts"), renderRunConfig());
77
+ write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features));
62
78
  files.push("src/run-config.ts");
63
79
 
64
80
  write(join(destination, "bin", "main.ts"), renderMain(options.name));
@@ -133,26 +149,44 @@ function newTsProject(): Project {
133
149
  });
134
150
  }
135
151
 
136
- function renderRunConfig(): string {
152
+ const FOUNDATION_FEATURES: ReadonlyArray<ScaffoldFeatureEntry> = [
153
+ {
154
+ name: "secrets",
155
+ importPath: "@cosmicdrift/kumiko-bundled-features/secrets",
156
+ exportName: "createSecretsFeature",
157
+ callExpression: "createSecretsFeature()",
158
+ },
159
+ {
160
+ name: "sessions",
161
+ importPath: "@cosmicdrift/kumiko-bundled-features/sessions",
162
+ exportName: "createSessionsFeature",
163
+ callExpression: "createSessionsFeature()",
164
+ },
165
+ ];
166
+
167
+ function renderRunConfig(features?: ReadonlyArray<ScaffoldFeatureEntry>): string {
137
168
  const project = newTsProject();
138
169
  const sf = project.createSourceFile("run-config.ts", "");
139
170
 
140
- sf.addImportDeclaration({
141
- moduleSpecifier: "@cosmicdrift/kumiko-bundled-features/secrets",
142
- namedImports: ["createSecretsFeature"],
143
- });
144
- sf.addImportDeclaration({
145
- moduleSpecifier: "@cosmicdrift/kumiko-bundled-features/sessions",
146
- namedImports: ["createSessionsFeature"],
147
- });
171
+ const effective = features && features.length > 0 ? features : FOUNDATION_FEATURES;
172
+ const grouped = new Map<string, string[]>();
173
+ for (const entry of effective) {
174
+ const existing = grouped.get(entry.importPath) ?? [];
175
+ if (!existing.includes(entry.exportName)) existing.push(entry.exportName);
176
+ grouped.set(entry.importPath, existing);
177
+ }
178
+ for (const [importPath, namedImports] of grouped) {
179
+ sf.addImportDeclaration({ moduleSpecifier: importPath, namedImports });
180
+ }
148
181
 
182
+ const callList = effective.map((f) => f.callExpression).join(", ");
149
183
  sf.addVariableStatement({
150
184
  declarationKind: VariableDeclarationKind.Const,
151
185
  isExported: true,
152
186
  declarations: [
153
187
  {
154
188
  name: "APP_FEATURES",
155
- initializer: "[createSecretsFeature(), createSessionsFeature()] as const",
189
+ initializer: `[${callList}] as const`,
156
190
  },
157
191
  ],
158
192
  });
@@ -161,9 +195,9 @@ function renderRunConfig(): string {
161
195
  0,
162
196
  [
163
197
  "// Single source of truth für die Feature-Komposition deiner App.",
164
- "// Bundled-Foundation: secrets + sessions. config/user/tenant/auth-email-password",
165
- "// werden via composeFeatures(includeBundled:true) automatisch ergänzt",
166
- "// wenn runProdApp mit `auth: {…}` aufgerufen wird (siehe bin/main.ts).",
198
+ "// config/user/tenant/auth-email-password werden via",
199
+ "// composeFeatures(includeBundled:true) automatisch ergänzt wenn",
200
+ "// runProdApp mit `auth: {…}` aufgerufen wird (siehe bin/main.ts).",
167
201
  "//",
168
202
  "// Neue features hinzufügen:",
169
203
  "// - bunx @cosmicdrift/kumiko-cli add feature <name> (DX-2, automatisch)",
@@ -0,0 +1,46 @@
1
+ // First-run welcome banner for `runDevApp({ welcomeBanner: true })`.
2
+ // Opt-in only — `bun dev` should not normalize this for apps that
3
+ // already log their own startup. Scaffolded apps (create-kumiko-app)
4
+ // flip it on so the first `bun dev` doesn't end on "Server läuft" with
5
+ // the user wondering where to click.
6
+
7
+ export type WelcomeBannerInput = {
8
+ /** Resolved listen-URL (`http://localhost:3000`). */
9
+ readonly url: string;
10
+ /** Login the seeded admin uses (from runDevApp options.auth.admin). */
11
+ readonly admin?: { readonly email: string; readonly password: string };
12
+ /** Hot-reload hint shown in the "add a feature" line. Defaults to
13
+ * `src/features/`. */
14
+ readonly featuresDir?: string;
15
+ /** Custom docs URL — defaults to the public docs site. */
16
+ readonly docsUrl?: string;
17
+ };
18
+
19
+ export function renderWelcomeBanner(input: WelcomeBannerInput): string {
20
+ const featuresDir = input.featuresDir ?? "src/features/";
21
+ const docsUrl = input.docsUrl ?? "https://docs.kumiko.rocks/quickstart";
22
+
23
+ const lines: string[] = [];
24
+ lines.push(`✓ kumiko-app läuft`);
25
+ lines.push(`→ Browser: ${input.url}`);
26
+ if (input.admin) {
27
+ lines.push(`→ Login als: ${input.admin.email} / ${input.admin.password}`);
28
+ }
29
+ lines.push(`→ Feature add: edit ${featuresDir}`);
30
+ lines.push(`→ Docs: ${docsUrl}`);
31
+
32
+ const innerWidth = Math.max(...lines.map((l) => stringWidth(l)));
33
+ const border = "─".repeat(innerWidth + 2);
34
+ const top = `┌${border}┐`;
35
+ const bottom = `└${border}┘`;
36
+ const padded = lines.map((l) => `│ ${l}${" ".repeat(innerWidth - stringWidth(l))} │`);
37
+ return [top, ...padded, bottom].join("\n");
38
+ }
39
+
40
+ // Plain monospace-cell width — counts each codepoint as one cell. Good
41
+ // enough for ASCII + the small set of arrows/checkmarks used above; if a
42
+ // real wide-char ever lands in the banner the row alignment will drift,
43
+ // caught visually by the snapshot test.
44
+ function stringWidth(s: string): number {
45
+ return [...s].length;
46
+ }