@cosmicdrift/kumiko-dev-server 0.12.2 → 0.14.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.
@@ -0,0 +1,118 @@
1
+ // DX-3.1 — walkthrough-snapshot-test. Reproduces the 3-command path from
2
+ // docs.kumiko.so/en/walkthrough/ in-process and asserts what the walkthrough
3
+ // claims. Catches drift in scaffoldApp + scaffoldAppFeature against the
4
+ // docs without an actual `bunx … && yarn install && bun run boot` CI run.
5
+ //
6
+ // What this test pins:
7
+ // - scaffoldApp produces the 6 files the walkthrough lists
8
+ // - scaffoldAppFeature scaffolds + auto-mounts (the diff-block shown)
9
+ // - composeFeatures(includeBundled:true) yields the exact feature-count
10
+ // the walkthrough advertises in "Expected output"
11
+
12
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
16
+ import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
17
+ import { createRegistry, defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
18
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
19
+ import { composeFeatures } from "../compose-features";
20
+ import { scaffoldApp } from "../scaffold-app";
21
+ import { scaffoldAppFeature } from "../scaffold-app-feature";
22
+
23
+ describe("walkthrough — DX-3.1 snapshot", () => {
24
+ let tmp: string;
25
+ let appRoot: string;
26
+
27
+ beforeEach(() => {
28
+ tmp = mkdtempSync(join(tmpdir(), "walkthrough-"));
29
+ appRoot = join(tmp, "my-notes");
30
+ });
31
+ afterEach(() => {
32
+ rmSync(tmp, { recursive: true, force: true });
33
+ });
34
+
35
+ test("Step 1 (kumiko new app) — produces walkthrough's 6 files", () => {
36
+ const result = scaffoldApp({ name: "my-notes", destination: appRoot });
37
+ expect(result.files).toEqual([
38
+ "package.json",
39
+ "tsconfig.json",
40
+ "src/run-config.ts",
41
+ "bin/main.ts",
42
+ ".env.example",
43
+ "README.md",
44
+ ]);
45
+ });
46
+
47
+ test("Step 2 (kumiko add feature) — auto-mounts + walkthrough diff matches", () => {
48
+ scaffoldApp({ name: "my-notes", destination: appRoot });
49
+ const result = scaffoldAppFeature({ name: "notes", appRoot });
50
+ expect(result.autoMounted).toBe(true);
51
+
52
+ const runConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
53
+ // Walkthrough's diff-block claims:
54
+ // + import { notesFeature } from "./features/notes";
55
+ // + notesFeature (in APP_FEATURES)
56
+ expect(runConfig).toContain(`import { notesFeature } from "./features/notes";`);
57
+ expect(runConfig).toContain("notesFeature");
58
+ // Foundation still mounted (createSecretsFeature + createSessionsFeature).
59
+ expect(runConfig).toContain("createSecretsFeature()");
60
+ expect(runConfig).toContain("createSessionsFeature()");
61
+ });
62
+
63
+ test("Step 3 (boot validation) — scaffolded run-config matches walkthrough's APP_FEATURES claim", () => {
64
+ scaffoldApp({ name: "my-notes", destination: appRoot });
65
+ scaffoldAppFeature({ name: "notes", appRoot });
66
+
67
+ // Text-assert: scaffolded run-config.ts contains exactly the 3 features
68
+ // the walkthrough's diff-block shows (secrets + sessions + notesFeature).
69
+ // Dynamic-import would fail because /tmp can't resolve @cosmicdrift/*
70
+ // workspace symlinks — instead we reproduce the equivalent APP_FEATURES
71
+ // array in-process below.
72
+ const runConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
73
+ expect(runConfig).toContain("createSecretsFeature()");
74
+ expect(runConfig).toContain("createSessionsFeature()");
75
+ expect(runConfig).toContain("notesFeature");
76
+ });
77
+
78
+ test("Step 3 (composeFeatures) — 3 explicit + 4 auto-mounted = 7 features", () => {
79
+ // Reproduces the scaffolded APP_FEATURES in-process. notesFeature gets
80
+ // a dummy defineFeature here — the scaffold-side of "notesFeature"
81
+ // (file-content) is pinned in test 2; this test pins the runtime-side
82
+ // (composeFeatures auto-prepend behaviour the walkthrough claims).
83
+ const notesFeature = defineFeature("notes", () => {});
84
+ const APP_FEATURES = [createSecretsFeature(), createSessionsFeature(), notesFeature];
85
+
86
+ const composed = composeFeatures(APP_FEATURES, { includeBundled: true });
87
+ // 3 explicit + 4 auto-mounted bundled = 7 total features.
88
+ expect(composed.length).toBe(7);
89
+
90
+ const composedNames = composed.map((f) => f.name).sort();
91
+ expect(composedNames).toEqual([
92
+ "auth-email-password",
93
+ "config",
94
+ "notes",
95
+ "secrets",
96
+ "sessions",
97
+ "tenant",
98
+ "user",
99
+ ]);
100
+
101
+ // validateBoot must pass (no missing-requires, no schema-errors).
102
+ expect(() => validateBoot(composed)).not.toThrow();
103
+ // Registry must contain all 7 features.
104
+ const registry = createRegistry(composed);
105
+ expect(registry.features.size).toBe(7);
106
+ });
107
+
108
+ test("bin/main.ts contains the auth.admin stub the walkthrough relies on", () => {
109
+ scaffoldApp({ name: "my-notes", destination: appRoot });
110
+ const main = readFileSync(join(appRoot, "bin/main.ts"), "utf-8");
111
+ // composeFeatures(includeBundled:true)-trigger is `auth: { admin: { … } }`.
112
+ // Walkthrough explicitly says this is what auto-mounts the 4 bundled features.
113
+ expect(main).toContain("auth: {");
114
+ expect(main).toContain("admin: {");
115
+ expect(main).toContain("memberships:");
116
+ expect(main).toContain("runProdApp");
117
+ });
118
+ });
@@ -82,7 +82,25 @@ const RUNTIME_EXTERNALS = [
82
82
  // Markierung scheitert bun build an dynamic-imports (z.B. drizzle-kit →
83
83
  // @libsql/client). Tree-Shake wirft sie eh aus dem Bundle — der Marker
84
84
  // schaltet nur das resolution-during-build ab. NICHT in runtime-deps.
85
- const BUILD_ONLY_EXTERNALS = ["meilisearch", "pino", "pino-pretty", "@aws-sdk/*"] as const;
85
+ //
86
+ // drizzle-kit's dialect-resolver macht dynamic-imports zu allen DB-driver-
87
+ // packages (planetscale/libsql/sqlite/neon/vercel/mysql2). Wir nutzen nur
88
+ // postgres → diese werden never-loaded zur Runtime, aber der Bundler will
89
+ // sie resolven. Aufgedeckt durch C1 Empfehlung 4 (bundle-smoke).
90
+ const BUILD_ONLY_EXTERNALS = [
91
+ "meilisearch",
92
+ "pino",
93
+ "pino-pretty",
94
+ "@aws-sdk/*",
95
+ "@planetscale/database",
96
+ "@libsql/client",
97
+ "better-sqlite3",
98
+ "@neondatabase/serverless",
99
+ "@vercel/postgres",
100
+ "mysql2",
101
+ // ink (kumiko-tui) hat react-devtools-core als dev-only transitive import.
102
+ "react-devtools-core",
103
+ ] as const;
86
104
 
87
105
  export type BuildServerBundleOptions = {
88
106
  /** App-Root. Default: process.cwd(). */
package/src/index.ts CHANGED
@@ -54,6 +54,13 @@ 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";
58
+ export { scaffoldApp } from "./scaffold-app";
59
+ export type {
60
+ ScaffoldAppFeatureOptions,
61
+ ScaffoldAppFeatureResult,
62
+ } from "./scaffold-app-feature";
63
+ export { runConfigPathForApp, scaffoldAppFeature } from "./scaffold-app-feature";
57
64
  export type {
58
65
  ScaffoldDeployOptions,
59
66
  ScaffoldDeployResult,
@@ -126,23 +126,28 @@ function readEnv(name: string): string | undefined {
126
126
  return value === undefined || value === "" ? undefined : value;
127
127
  }
128
128
 
129
- // Parse `KUMIKO_DRY_RUN_ENV=…` into a DryRunMode. Truthy "1" aliases
130
- // "human" the most common deploy-Q quick-look. Unknown values are
131
- // warned-and-ignored: a typo like `=humans` would otherwise look like
132
- // a confused boot 30 seconds later when the schema fails.
133
- function parseDryRunMode(raw: string | undefined): DryRunMode | null {
129
+ // `boot` is the C1 smoke-test path — validators run, no DB/Redis connect,
130
+ // exit after registry-build. Render-modes (human|json|pulumi|k8s|1)
131
+ // inspect the env-schema and exit before any feature wiring.
132
+ type RunMode = DryRunMode | "boot";
133
+
134
+ function parseRunMode(raw: string | undefined): RunMode | null {
134
135
  if (!raw) return null;
135
136
  const v = raw.toLowerCase();
136
137
  if (v === "1" || v === "true" || v === "human") return "human";
137
- if (v === "json" || v === "pulumi" || v === "k8s") return v;
138
+ if (v === "json" || v === "pulumi" || v === "k8s" || v === "boot") return v;
138
139
  // biome-ignore lint/suspicious/noConsole: boot-time warn for typo discovery
139
140
  console.warn(
140
141
  `[runProdApp] KUMIKO_DRY_RUN_ENV="${raw}" unrecognized ` +
141
- `(expected 1|human|json|pulumi|k8s); continuing with normal boot.`,
142
+ `(expected 1|human|json|pulumi|k8s|boot); continuing with normal boot.`,
142
143
  );
143
144
  return null;
144
145
  }
145
146
 
147
+ function isRenderMode(mode: RunMode | null): mode is DryRunMode {
148
+ return mode !== null && mode !== "boot";
149
+ }
150
+
146
151
  function defaultBootErrorReporter(err: KumikoBootError): never {
147
152
  // biome-ignore lint/suspicious/noConsole: boot-time error, no logger configured yet
148
153
  console.error(err.format());
@@ -485,13 +490,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
485
490
  // at parse-time before the polyfill loads. Plain strings + .regex /
486
491
  // .min / .email / .url cover every env-var shape we've actually
487
492
  // needed in 9.1's audit (37 references, 25 distinct vars).
493
+ const envSource = options.envSource ?? process.env;
494
+ const runMode = parseRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
488
495
  if (options.envSchema) {
489
- const envSource = options.envSource ?? process.env;
490
- const dryRunMode = parseDryRunMode(envSource["KUMIKO_DRY_RUN_ENV"]);
491
- if (dryRunMode !== null) {
496
+ if (isRenderMode(runMode)) {
492
497
  // biome-ignore lint/suspicious/noConsole: dry-run output IS the deliverable
493
498
  console.log(
494
- renderDryRun(options.envSchema, dryRunMode, {
499
+ renderDryRun(options.envSchema, runMode, {
495
500
  ...(options.pulumiPrefix ? { pulumiPrefix: options.pulumiPrefix } : {}),
496
501
  sources: options.envSchema.sources,
497
502
  }),
@@ -504,6 +509,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
504
509
  }
505
510
  return makeDryRunHandle();
506
511
  }
512
+ // boot-mode AND normal-boot both run env-validation. boot-mode wants
513
+ // a real env-check (all required vars present + schema-valid) before
514
+ // it asserts feature-wiring works.
507
515
  try {
508
516
  parseEnv(options.envSchema.schema, envSource, {
509
517
  sources: options.envSchema.sources,
@@ -554,6 +562,22 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
554
562
  validateBoot(features);
555
563
  const registry = createRegistry(features);
556
564
 
565
+ // C1 boot-mode exit: validators ran, registry built, no DB/Redis
566
+ // operations executed yet (postgres.js + ioredis are lazy). Tear down
567
+ // the lazy clients so Bun doesn't keep them open, then exit / return.
568
+ if (runMode === "boot") {
569
+ // biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
570
+ console.log(
571
+ `[runProdApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
572
+ );
573
+ await closeDb();
574
+ redis.disconnect();
575
+ if (options.envSource === undefined) {
576
+ process.exit(0);
577
+ }
578
+ return makeDryRunHandle();
579
+ }
580
+
557
581
  // Sprint-8a Tier-Composition auto-wire: scan features for a
558
582
  // tenantTierResolver-extension. If found AND user didn't supply own
559
583
  // effectiveFeatures, build the resolver here (db + registry are
@@ -0,0 +1,154 @@
1
+ // scaffoldAppFeature — DX-2. Scaffolds a fresh feature inside an
2
+ // existing Kumiko-app workspace + auto-mounts it in src/run-config.ts.
3
+ //
4
+ // Sister to `scaffoldFeature` (which targets samples/recipes/ for the
5
+ // framework workspace). This one targets `src/features/<name>/` of an
6
+ // already-scaffolded app (output of `kumiko new app`).
7
+ //
8
+ // Auto-mount via ts-morph: opens src/run-config.ts, finds
9
+ // `export const APP_FEATURES = [...]`, prepends import + appends entry.
10
+ // User's promise "defineFeature → nichts woanders eintragen" is met
11
+ // for the run-config side. Drizzle FEATURE_IMPORT_REGISTRY is NOT
12
+ // touched here — DX-4 auto-discovery resolves that.
13
+
14
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
+ import { join, resolve } from "node:path";
16
+ import { Project, SyntaxKind } from "ts-morph";
17
+
18
+ export type ScaffoldAppFeatureOptions = {
19
+ /** kebab-case feature name (e.g. "product-catalog"). */
20
+ readonly name: string;
21
+ /** App workspace root. Defaults to cwd. */
22
+ readonly appRoot?: string;
23
+ };
24
+
25
+ export type ScaffoldAppFeatureResult = {
26
+ readonly featureName: string;
27
+ readonly featureDir: string;
28
+ readonly files: readonly string[];
29
+ /** Whether src/run-config.ts was auto-mounted. False if run-config
30
+ * is missing — caller gets the scaffolded files but must hand-mount. */
31
+ readonly autoMounted: boolean;
32
+ };
33
+
34
+ const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
35
+
36
+ export function scaffoldAppFeature(options: ScaffoldAppFeatureOptions): ScaffoldAppFeatureResult {
37
+ if (!KEBAB_RE.test(options.name)) {
38
+ throw new Error(
39
+ `scaffoldAppFeature: name must be kebab-case (a-z, 0-9, -); got "${options.name}"`,
40
+ );
41
+ }
42
+ const appRoot = resolve(options.appRoot ?? process.cwd());
43
+ const featureDir = join(appRoot, "src", "features", options.name);
44
+ if (existsSync(featureDir)) {
45
+ throw new Error(`scaffoldAppFeature: ${featureDir} already exists — refusing to overwrite`);
46
+ }
47
+ mkdirSync(featureDir, { recursive: true });
48
+
49
+ const files: string[] = [];
50
+ const featureFile = join(featureDir, "feature.ts");
51
+ writeFileSync(featureFile, renderFeature(options.name));
52
+ files.push(`src/features/${options.name}/feature.ts`);
53
+
54
+ const indexFile = join(featureDir, "index.ts");
55
+ writeFileSync(indexFile, renderIndex(options.name));
56
+ files.push(`src/features/${options.name}/index.ts`);
57
+
58
+ const runConfigPath = join(appRoot, "src", "run-config.ts");
59
+ const autoMounted = existsSync(runConfigPath)
60
+ ? mountInRunConfig(runConfigPath, options.name)
61
+ : false;
62
+
63
+ return {
64
+ featureName: options.name,
65
+ featureDir,
66
+ files,
67
+ autoMounted,
68
+ };
69
+ }
70
+
71
+ function renderFeature(name: string): string {
72
+ const camel = kebabToCamel(name);
73
+ return `// ${name} feature — scaffolded by \`kumiko add feature\`. Edit freely.
74
+ //
75
+ // Doc-Pointer: https://docs.kumiko.so/en/patterns/ for the \`r.*\` API
76
+ // (r.entity, r.writeHandler, r.queryHandler, hooks, …).
77
+
78
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
79
+
80
+ export const ${camel}Feature = defineFeature("${name}", (r) => {
81
+ // Starter: declare an entity. Drop and replace with your domain.
82
+ r.entity("${name}-item", {
83
+ fields: {
84
+ title: { type: "text", required: true },
85
+ },
86
+ });
87
+ });
88
+ `;
89
+ }
90
+
91
+ function renderIndex(name: string): string {
92
+ const camel = kebabToCamel(name);
93
+ return `export { ${camel}Feature } from "./feature";\n`;
94
+ }
95
+
96
+ function kebabToCamel(name: string): string {
97
+ return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
98
+ }
99
+
100
+ // ts-morph: open run-config, prepend import, append APP_FEATURES entry.
101
+ // Returns true on success, throws on shape-mismatch (caller swallows the
102
+ // scaffolded files but warns).
103
+ function mountInRunConfig(runConfigPath: string, name: string): boolean {
104
+ const camel = kebabToCamel(name);
105
+ const project = new Project({
106
+ skipAddingFilesFromTsConfig: true,
107
+ skipFileDependencyResolution: true,
108
+ });
109
+ const sf = project.addSourceFileAtPath(runConfigPath);
110
+
111
+ // Already mounted? short-circuit (idempotent re-runs).
112
+ const existingImport = sf.getImportDeclaration(`./features/${name}`);
113
+ if (existingImport) return true;
114
+
115
+ // 1. Prepend import after the last existing import.
116
+ const imports = sf.getImportDeclarations();
117
+ const insertIndex = imports.length > 0 ? imports[imports.length - 1]!.getChildIndex() + 1 : 0;
118
+ sf.insertImportDeclaration(insertIndex, {
119
+ moduleSpecifier: `./features/${name}`,
120
+ namedImports: [`${camel}Feature`],
121
+ });
122
+
123
+ // 2. Find `export const APP_FEATURES = [...]` and append the new entry.
124
+ const appFeaturesDecl = sf.getVariableDeclaration("APP_FEATURES");
125
+ if (!appFeaturesDecl) {
126
+ throw new Error(
127
+ `mountInRunConfig: ${runConfigPath} has no 'APP_FEATURES' declaration — ` +
128
+ `cannot auto-mount. Hand-edit: add '${camel}Feature' to APP_FEATURES.`,
129
+ );
130
+ }
131
+ const initializer =
132
+ appFeaturesDecl.getInitializerIfKind(SyntaxKind.AsExpression) ??
133
+ appFeaturesDecl.getInitializer();
134
+ if (!initializer) {
135
+ throw new Error(`mountInRunConfig: APP_FEATURES has no initializer — cannot auto-mount.`);
136
+ }
137
+ // Strip `as const` wrapper if present.
138
+ const arr =
139
+ initializer.getKind() === SyntaxKind.AsExpression
140
+ ? initializer.getFirstChildByKind(SyntaxKind.ArrayLiteralExpression)
141
+ : initializer.asKind(SyntaxKind.ArrayLiteralExpression);
142
+ if (!arr) {
143
+ throw new Error(`mountInRunConfig: APP_FEATURES is not an array literal — cannot auto-mount.`);
144
+ }
145
+ arr.addElement(`${camel}Feature`);
146
+
147
+ sf.saveSync();
148
+ return true;
149
+ }
150
+
151
+ // Re-export so consumers can hint at the file (e.g. for kumiko-cli output).
152
+ export function runConfigPathForApp(appRoot: string): string {
153
+ return join(appRoot, "src", "run-config.ts");
154
+ }