@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 +3 -3
- package/src/__tests__/scaffold-app.test.ts +37 -0
- package/src/__tests__/welcome-banner.test.ts +42 -0
- package/src/index.ts +6 -1
- package/src/run-dev-app.ts +29 -1
- package/src/scaffold-app.ts +48 -14
- package/src/welcome-banner.ts +46 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "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.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "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 {
|
|
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";
|
package/src/run-dev-app.ts
CHANGED
|
@@ -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
|
-
|
|
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):
|
package/src/scaffold-app.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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:
|
|
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
|
-
"//
|
|
165
|
-
"//
|
|
166
|
-
"//
|
|
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
|
+
}
|