@cosmicdrift/kumiko-dev-server 0.294.1 → 0.295.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.294.1",
3
+ "version": "0.295.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. 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": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -59,10 +59,10 @@
59
59
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
60
60
  },
61
61
  "dependencies": {
62
- "@cosmicdrift/kumiko-bundled-features": "0.294.1",
63
- "@cosmicdrift/kumiko-framework": "0.294.1",
64
- "@cosmicdrift/kumiko-headless": "0.294.1",
65
- "@cosmicdrift/kumiko-server-runtime": "0.294.1",
62
+ "@cosmicdrift/kumiko-bundled-features": "0.295.0",
63
+ "@cosmicdrift/kumiko-framework": "0.295.0",
64
+ "@cosmicdrift/kumiko-headless": "0.295.0",
65
+ "@cosmicdrift/kumiko-server-runtime": "0.295.0",
66
66
  "ts-morph": "^28.0.0"
67
67
  },
68
68
  "publishConfig": {
@@ -0,0 +1,45 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveFrameworkVersion } from "../framework-version";
6
+
7
+ const tmpRoots: string[] = [];
8
+
9
+ function makeTree(): { root: string; nested: string } {
10
+ const root = mkdtempSync(join(tmpdir(), "framework-version-"));
11
+ tmpRoots.push(root);
12
+ const nested = join(root, "a", "b");
13
+ mkdirSync(nested, { recursive: true });
14
+ return { root, nested };
15
+ }
16
+
17
+ afterEach(() => {
18
+ for (const root of tmpRoots.splice(0)) rmSync(root, { recursive: true, force: true });
19
+ });
20
+
21
+ describe("resolveFrameworkVersion", () => {
22
+ test("resolves the real dev-server version with a caret range", async () => {
23
+ const own: { version: string } = await Bun.file(
24
+ join(import.meta.dir, "../../package.json"),
25
+ ).json();
26
+ expect(resolveFrameworkVersion()).toBe(`^${own.version}`);
27
+ });
28
+
29
+ test("skips corrupt and foreign package.json files on the way up", () => {
30
+ const { root, nested } = makeTree();
31
+ writeFileSync(
32
+ join(root, "package.json"),
33
+ '{"name":"@cosmicdrift/kumiko-dev-server","version":"1.2.3"}',
34
+ );
35
+ writeFileSync(join(root, "a", "package.json"), '{"name":"other","version":"9.9.9"}');
36
+ writeFileSync(join(nested, "package.json"), "{ not json");
37
+ expect(resolveFrameworkVersion(nested)).toBe("^1.2.3");
38
+ });
39
+
40
+ test("ignores a dev-server manifest without a version", () => {
41
+ const { root, nested } = makeTree();
42
+ writeFileSync(join(root, "package.json"), '{"name":"@cosmicdrift/kumiko-dev-server"}');
43
+ expect(resolveFrameworkVersion(nested)).toBeUndefined();
44
+ });
45
+ });
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { scaffoldApp } from "../scaffold-app";
7
+ import { type ScaffoldTestSetup, scaffoldApp } from "../scaffold-app";
8
8
 
9
9
  const SCAFFOLD_FILES = [
10
10
  "package.json",
@@ -32,6 +32,17 @@ const SCAFFOLD_FILES = [
32
32
  "README.md",
33
33
  ] as const;
34
34
 
35
+ const STUB_TEST_SETUP: ScaffoldTestSetup = {
36
+ files: {
37
+ "bunfig.toml": "# stub unit bunfig\n",
38
+ "e2e/smoke.spec.ts": "// stub spec\n",
39
+ "src/__tests__/stub.test.ts": "// stub test\n",
40
+ },
41
+ scripts: { test: "stub-unit", "test:integration": "stub-integration", e2e: "stub-e2e" },
42
+ devDependencies: { "@cosmicdrift/kumiko-testing": "stub-version", "@playwright/test": "^1.0.0" },
43
+ rulesMarkdown: "## Testing\n\n- stub rule\n",
44
+ };
45
+
35
46
  describe("scaffoldApp", () => {
36
47
  let tmp: string;
37
48
  beforeEach(() => {
@@ -74,6 +85,92 @@ describe("scaffoldApp", () => {
74
85
  expect(pkg.scripts["lint"]).toBe("biome check .");
75
86
  });
76
87
 
88
+ test("without testSetup the scaffold keeps the legacy bunfig pair and test script", async () => {
89
+ const dest = join(tmp, "my-shop");
90
+ const result = await scaffoldApp({ name: "my-shop", destination: dest });
91
+
92
+ const pkg = JSON.parse(readFileSync(join(dest, "package.json"), "utf-8")) as {
93
+ scripts: Record<string, string>;
94
+ devDependencies: Record<string, string>;
95
+ };
96
+ expect(pkg.scripts["test"]).toBe("bun --config=bunfig.ci.toml test --dots");
97
+ expect(Object.keys(pkg.scripts)).not.toContain("e2e");
98
+ expect(Object.keys(pkg.devDependencies)).not.toContain("@cosmicdrift/kumiko-testing");
99
+ expect(result.files).toContain("bunfig.ci.toml");
100
+ expect(existsSync(join(dest, "e2e"))).toBe(false);
101
+ expect(readFileSync(join(dest, "README.md"), "utf-8")).not.toContain("## Testing");
102
+ });
103
+
104
+ test("testSetup: factory gets name + version, files land (nested dirs) and are reported", async () => {
105
+ const dest = join(tmp, "my-shop");
106
+ let received: { appName: string; frameworkVersion: string } | undefined;
107
+ const result = await scaffoldApp({
108
+ name: "my-shop",
109
+ destination: dest,
110
+ frameworkVersion: "^0.13.0",
111
+ testSetup: (input) => {
112
+ received = input;
113
+ return STUB_TEST_SETUP;
114
+ },
115
+ });
116
+
117
+ expect(received).toEqual({ appName: "my-shop", frameworkVersion: "^0.13.0" });
118
+ for (const rel of Object.keys(STUB_TEST_SETUP.files)) {
119
+ expect(result.files).toContain(rel);
120
+ expect(readFileSync(join(dest, rel), "utf-8")).toBe(STUB_TEST_SETUP.files[rel] as string);
121
+ }
122
+ expect(existsSync(join(dest, "bunfig.ci.toml"))).toBe(false);
123
+ expect(result.files.filter((f) => f === "bunfig.toml")).toHaveLength(1);
124
+ });
125
+
126
+ test("testSetup: scripts replace test in place, devDependencies merge sorted, rules land in README", async () => {
127
+ const dest = join(tmp, "my-shop");
128
+ await scaffoldApp({ name: "my-shop", destination: dest, testSetup: () => STUB_TEST_SETUP });
129
+
130
+ const pkg = JSON.parse(readFileSync(join(dest, "package.json"), "utf-8")) as {
131
+ scripts: Record<string, string>;
132
+ devDependencies: Record<string, string>;
133
+ };
134
+ expect(pkg.scripts["test"]).toBe("stub-unit");
135
+ expect(Object.keys(pkg.scripts)).toEqual([
136
+ "dev",
137
+ "build",
138
+ "start",
139
+ "boot",
140
+ "typecheck",
141
+ "lint",
142
+ "test",
143
+ "test:integration",
144
+ "e2e",
145
+ "schema:apply",
146
+ "schema:generate",
147
+ ]);
148
+ expect(pkg.devDependencies["@cosmicdrift/kumiko-testing"]).toBe("stub-version");
149
+ const names = Object.keys(pkg.devDependencies);
150
+ expect(names).toEqual([...names].sort());
151
+ expect(names).toContain("@biomejs/biome");
152
+
153
+ const readme = readFileSync(join(dest, "README.md"), "utf-8");
154
+ expect(readme).toContain("## Architecture");
155
+ expect(readme.endsWith(STUB_TEST_SETUP.rulesMarkdown)).toBe(true);
156
+ });
157
+
158
+ test("tsconfig and biome cover the e2e sources", async () => {
159
+ const dest = join(tmp, "my-shop");
160
+ await scaffoldApp({ name: "my-shop", destination: dest, testSetup: () => STUB_TEST_SETUP });
161
+
162
+ const tsconfig = JSON.parse(readFileSync(join(dest, "tsconfig.json"), "utf-8")) as {
163
+ include: string[];
164
+ };
165
+ expect(tsconfig.include).toEqual(expect.arrayContaining(["e2e", "playwright.config.ts"]));
166
+ const biome = JSON.parse(readFileSync(join(dest, "biome.json"), "utf-8")) as {
167
+ files: { includes: string[] };
168
+ };
169
+ expect(biome.files.includes).toEqual(
170
+ expect.arrayContaining(["e2e/**", "playwright.config.ts"]),
171
+ );
172
+ });
173
+
77
174
  test("src/seed.ts bare return has a skip comment directly above it (kumiko-guard-silent-skip)", async () => {
78
175
  const dest = join(tmp, "my-shop");
79
176
  await scaffoldApp({ name: "my-shop", destination: dest });
@@ -0,0 +1,39 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
4
+
5
+ const DEV_SERVER_PACKAGE = "@cosmicdrift/kumiko-dev-server";
6
+
7
+ interface DevServerManifest {
8
+ readonly name: typeof DEV_SERVER_PACKAGE;
9
+ readonly version: string;
10
+ }
11
+
12
+ function isDevServerManifest(value: unknown): value is DevServerManifest {
13
+ return (
14
+ typeof value === "object" &&
15
+ value !== null &&
16
+ "name" in value &&
17
+ value.name === DEV_SERVER_PACKAGE &&
18
+ "version" in value &&
19
+ typeof value.version === "string" &&
20
+ value.version !== ""
21
+ );
22
+ }
23
+
24
+ // All @cosmicdrift packages release in lockstep (changesets fixed-group), so
25
+ // the dev-server's own version is the framework version.
26
+ export function resolveFrameworkVersion(startDir: string = import.meta.dir): string | undefined {
27
+ let dir = startDir;
28
+ while (true) {
29
+ const candidate = join(dir, "package.json");
30
+ if (existsSync(candidate)) {
31
+ // A corrupt or foreign package.json on the way up must not abort the search.
32
+ const manifest = parseJsonSafe<unknown>(readFileSync(candidate, "utf-8"), undefined);
33
+ if (isDevServerManifest(manifest)) return `^${manifest.version}`;
34
+ }
35
+ const parent = dirname(dir);
36
+ if (parent === dir) return undefined;
37
+ dir = parent;
38
+ }
39
+ }
package/src/index.ts CHANGED
@@ -65,6 +65,7 @@ export type {
65
65
  FewShotEntry,
66
66
  } from "./few-shot-corpus";
67
67
  export { buildFewShotCorpus, pathToId } from "./few-shot-corpus";
68
+ export { resolveFrameworkVersion } from "./framework-version";
68
69
  export type { RunDevAppAuthOptions, RunDevAppOptions, SeedFn } from "./run-dev-app";
69
70
  export { runDevApp } from "./run-dev-app";
70
71
  export type {
@@ -12,7 +12,7 @@
12
12
  // Static files (package.json, tsconfig, .env, README) stay text-based.
13
13
 
14
14
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
- import { join, relative, resolve } from "node:path";
15
+ import { dirname, join, relative, resolve } from "node:path";
16
16
  import {
17
17
  collectTableMetas,
18
18
  generateMigration,
@@ -46,6 +46,20 @@ export type ScaffoldFeatureEntry = {
46
46
  readonly callArgs?: readonly unknown[];
47
47
  };
48
48
 
49
+ // Structural twin of `ScaffoldTestSetup` from @cosmicdrift/kumiko-testing/scaffold —
50
+ // dev-server cannot import that package (it depends on dev-server), callers pass it in.
51
+ export type ScaffoldTestSetup = {
52
+ readonly files: Readonly<Record<string, string>>;
53
+ readonly scripts: Readonly<Record<string, string>>;
54
+ readonly devDependencies: Readonly<Record<string, string>>;
55
+ readonly rulesMarkdown: string;
56
+ };
57
+
58
+ export type ScaffoldTestSetupFactory = (input: {
59
+ readonly appName: string;
60
+ readonly frameworkVersion: string;
61
+ }) => ScaffoldTestSetup;
62
+
49
63
  export type ScaffoldAppOptions = {
50
64
  /** kebab-case app name (e.g. "my-shop"). Becomes package-name + folder. */
51
65
  readonly name: string;
@@ -62,6 +76,10 @@ export type ScaffoldAppOptions = {
62
76
  * (the historical foundation). create-kumiko-app passes the picker output
63
77
  * here so the generated APP_FEATURES reflects the user's selection. */
64
78
  readonly features?: ReadonlyArray<ScaffoldFeatureEntry>;
79
+ /** Test wiring (bunfigs, scripts, example tests, e2e) — `renderTestSetup` from
80
+ * @cosmicdrift/kumiko-testing/scaffold. Without it the app gets the legacy
81
+ * bunfig pair and no example tests. */
82
+ readonly testSetup?: ScaffoldTestSetupFactory;
65
83
  };
66
84
 
67
85
  export type ScaffoldAppResult = {
@@ -80,6 +98,7 @@ export async function scaffoldApp(options: ScaffoldAppOptions): Promise<Scaffold
80
98
  throw new Error(`scaffoldApp: ${destination} already exists — refusing to overwrite`);
81
99
  }
82
100
  const version = options.frameworkVersion ?? "*";
101
+ const testSetup = options.testSetup?.({ appName: options.name, frameworkVersion: version });
83
102
 
84
103
  mkdirSync(join(destination, "bin"), { recursive: true });
85
104
  mkdirSync(join(destination, "src"), { recursive: true });
@@ -87,7 +106,7 @@ export async function scaffoldApp(options: ScaffoldAppOptions): Promise<Scaffold
87
106
 
88
107
  const files: string[] = [];
89
108
 
90
- write(join(destination, "package.json"), renderPackageJson(options.name, version));
109
+ write(join(destination, "package.json"), renderPackageJson(options.name, version, testSetup));
91
110
  files.push("package.json");
92
111
 
93
112
  write(join(destination, "tsconfig.json"), renderTsconfig());
@@ -96,11 +115,13 @@ export async function scaffoldApp(options: ScaffoldAppOptions): Promise<Scaffold
96
115
  write(join(destination, "biome.json"), renderBiomeJson());
97
116
  files.push("biome.json");
98
117
 
99
- write(join(destination, "bunfig.toml"), renderBunfigToml());
100
- files.push("bunfig.toml");
118
+ if (testSetup === undefined) {
119
+ write(join(destination, "bunfig.toml"), renderBunfigToml());
120
+ files.push("bunfig.toml");
101
121
 
102
- write(join(destination, "bunfig.ci.toml"), renderBunfigCiToml());
103
- files.push("bunfig.ci.toml");
122
+ write(join(destination, "bunfig.ci.toml"), renderBunfigCiToml());
123
+ files.push("bunfig.ci.toml");
124
+ }
104
125
 
105
126
  write(join(destination, "src", "run-config.ts"), renderRunConfig(options.features));
106
127
  files.push("src/run-config.ts");
@@ -154,7 +175,18 @@ export async function scaffoldApp(options: ScaffoldAppOptions): Promise<Scaffold
154
175
  }
155
176
  }
156
177
 
157
- write(join(destination, "README.md"), renderReadme(options.name, options.features));
178
+ if (testSetup !== undefined) {
179
+ for (const [rel, content] of Object.entries(testSetup.files)) {
180
+ mkdirSync(dirname(join(destination, rel)), { recursive: true });
181
+ write(join(destination, rel), content);
182
+ files.push(rel);
183
+ }
184
+ }
185
+
186
+ write(
187
+ join(destination, "README.md"),
188
+ `${renderReadme(options.name, options.features)}${testSetup === undefined ? "" : `\n${testSetup.rulesMarkdown}`}`,
189
+ );
158
190
  files.push("README.md");
159
191
 
160
192
  return { destination, files, appName: options.name };
@@ -165,7 +197,11 @@ function write(path: string, content: string): void {
165
197
  writeFileSync(path, content);
166
198
  }
167
199
 
168
- function renderPackageJson(name: string, version: string): string {
200
+ function renderPackageJson(
201
+ name: string,
202
+ version: string,
203
+ testSetup: ScaffoldTestSetup | undefined,
204
+ ): string {
169
205
  return `${JSON.stringify(
170
206
  {
171
207
  name,
@@ -179,7 +215,7 @@ function renderPackageJson(name: string, version: string): string {
179
215
  boot: "KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts",
180
216
  typecheck: "tsc --noEmit",
181
217
  lint: "biome check .",
182
- test: "bun --config=bunfig.ci.toml test --dots",
218
+ ...(testSetup?.scripts ?? { test: "bun --config=bunfig.ci.toml test --dots" }),
183
219
  "schema:apply": "bun kumiko-schema apply",
184
220
  "schema:generate": "bun kumiko-schema generate",
185
221
  },
@@ -193,7 +229,7 @@ function renderPackageJson(name: string, version: string): string {
193
229
  "react-dom": "^19.2.6",
194
230
  zod: "^4.4.3",
195
231
  },
196
- devDependencies: {
232
+ devDependencies: sortedByName({
197
233
  "@biomejs/biome": "^2.4.15",
198
234
  "@tailwindcss/cli": "^4.3.0",
199
235
  "@types/react": "^19.2.0",
@@ -201,13 +237,18 @@ function renderPackageJson(name: string, version: string): string {
201
237
  "bun-types": "^1.4.0",
202
238
  tailwindcss: "^4.3.0",
203
239
  typescript: "^6.0.3",
204
- },
240
+ ...testSetup?.devDependencies,
241
+ }),
205
242
  },
206
243
  null,
207
244
  2,
208
245
  )}\n`;
209
246
  }
210
247
 
248
+ function sortedByName(record: Readonly<Record<string, string>>): Record<string, string> {
249
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : 1)));
250
+ }
251
+
211
252
  function renderTsconfig(): string {
212
253
  return `${JSON.stringify(
213
254
  {
@@ -226,7 +267,7 @@ function renderTsconfig(): string {
226
267
  jsx: "react-jsx",
227
268
  noEmit: true,
228
269
  },
229
- include: ["bin", "src", "kumiko"],
270
+ include: ["bin", "src", "kumiko", "e2e", "playwright.config.ts"],
230
271
  },
231
272
  null,
232
273
  2,
@@ -244,7 +285,15 @@ function renderBiomeJson(): string {
244
285
  defaultBranch: "main",
245
286
  },
246
287
  files: {
247
- includes: ["src/**", "bin/**", "kumiko/**", "!**/dist", "!kumiko/migrations"],
288
+ includes: [
289
+ "src/**",
290
+ "bin/**",
291
+ "kumiko/**",
292
+ "e2e/**",
293
+ "playwright.config.ts",
294
+ "!**/dist",
295
+ "!kumiko/migrations",
296
+ ],
248
297
  },
249
298
  formatter: {
250
299
  enabled: true,