@cosmicdrift/kumiko-dev-server 0.328.1 → 0.330.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.
Files changed (125) hide show
  1. package/README.md +17 -0
  2. package/bin/kumiko-build.ts +3 -2
  3. package/bin/kumiko-dev.ts +1 -1
  4. package/bin/kumiko-init-deploy.ts +1 -1
  5. package/bin/kumiko-schema-check.ts +4 -1
  6. package/dist/build-server-bundle.d.ts +31 -0
  7. package/dist/build-server-bundle.js +282 -0
  8. package/dist/build.d.ts +2 -0
  9. package/dist/build.js +5 -0
  10. package/dist/cli.d.ts +5 -0
  11. package/dist/cli.js +9 -0
  12. package/dist/codegen/index.d.ts +4 -0
  13. package/dist/codegen/index.js +4 -0
  14. package/dist/codegen/render.d.ts +55 -0
  15. package/dist/codegen/render.js +273 -0
  16. package/dist/codegen/run-codegen.d.ts +20 -0
  17. package/dist/codegen/run-codegen.js +219 -0
  18. package/dist/codegen/scan-events.d.ts +76 -0
  19. package/dist/codegen/scan-events.js +447 -0
  20. package/dist/codegen/watch.d.ts +33 -0
  21. package/dist/codegen/watch.js +112 -0
  22. package/dist/compose-stacks.d.ts +60 -0
  23. package/dist/compose-stacks.js +127 -0
  24. package/dist/crash-tracker.d.ts +17 -0
  25. package/dist/crash-tracker.js +37 -0
  26. package/dist/create-kumiko-server.d.ts +195 -0
  27. package/dist/create-kumiko-server.js +1040 -0
  28. package/dist/env-schema.d.ts +15 -0
  29. package/dist/env-schema.js +60 -0
  30. package/dist/few-shot-corpus.d.ts +72 -0
  31. package/dist/few-shot-corpus.js +262 -0
  32. package/dist/framework-version.d.ts +1 -0
  33. package/dist/framework-version.js +31 -0
  34. package/dist/index.d.ts +22 -0
  35. package/dist/index.js +33 -0
  36. package/dist/init-deploy-cli.d.ts +10 -0
  37. package/dist/init-deploy-cli.js +111 -0
  38. package/dist/kebab.d.ts +1 -0
  39. package/{src/kebab.ts → dist/kebab.js} +2 -3
  40. package/dist/run-dev-app.d.ts +174 -0
  41. package/dist/run-dev-app.js +403 -0
  42. package/dist/scaffold-app-feature.d.ts +16 -0
  43. package/dist/scaffold-app-feature.js +240 -0
  44. package/dist/scaffold-app.d.ts +45 -0
  45. package/dist/scaffold-app.js +852 -0
  46. package/dist/scaffold-demo-tasks.d.ts +8 -0
  47. package/{src/scaffold-demo-tasks.ts → dist/scaffold-demo-tasks.js} +69 -98
  48. package/dist/scaffold-deploy.d.ts +92 -0
  49. package/dist/scaffold-deploy.js +254 -0
  50. package/dist/scaffold-feature.d.ts +26 -0
  51. package/dist/scaffold-feature.js +163 -0
  52. package/dist/schema-apply.d.ts +11 -0
  53. package/dist/schema-apply.js +95 -0
  54. package/dist/schema-check-core.d.ts +2 -0
  55. package/{src/schema-check-core.ts → dist/schema-check-core.js} +8 -10
  56. package/dist/schema-tables-auth-mode.d.ts +2 -0
  57. package/{src/schema-tables-auth-mode.ts → dist/schema-tables-auth-mode.js} +2 -10
  58. package/dist/schema-tables-minimal.d.ts +2 -0
  59. package/{src/schema-tables-minimal.ts → dist/schema-tables-minimal.js} +2 -11
  60. package/dist/setup-test-stack-from-features.d.ts +11 -0
  61. package/dist/setup-test-stack-from-features.js +42 -0
  62. package/dist/welcome-banner.d.ts +15 -0
  63. package/dist/welcome-banner.js +27 -0
  64. package/package.json +71 -24
  65. package/src/changes.json +6 -0
  66. package/src/__tests__/build-prod-bundle.integration.test.ts +0 -282
  67. package/src/__tests__/build-server-bundle.test.ts +0 -228
  68. package/src/__tests__/classify-change.test.ts +0 -87
  69. package/src/__tests__/compose-stacks.test.ts +0 -273
  70. package/src/__tests__/config-resolver-default.integration.test.ts +0 -165
  71. package/src/__tests__/crash-tracker.test.ts +0 -89
  72. package/src/__tests__/create-kumiko-server-errors.test.ts +0 -54
  73. package/src/__tests__/create-kumiko-server-options.test.ts +0 -33
  74. package/src/__tests__/create-kumiko-server.integration.test.ts +0 -1007
  75. package/src/__tests__/discover-format.test.ts +0 -32
  76. package/src/__tests__/env-schema.integration.test.ts +0 -248
  77. package/src/__tests__/env-schema.test.ts +0 -132
  78. package/src/__tests__/few-shot-corpus.test.ts +0 -316
  79. package/src/__tests__/framework-version.test.ts +0 -45
  80. package/src/__tests__/init-deploy-cli.test.ts +0 -113
  81. package/src/__tests__/kumiko-schema-check.test.ts +0 -56
  82. package/src/__tests__/merge-extra-context.test.ts +0 -67
  83. package/src/__tests__/resolve-stylesheet.test.ts +0 -132
  84. package/src/__tests__/run-dev-app-boot-validation.test.ts +0 -38
  85. package/src/__tests__/run-dev-app.integration.test.ts +0 -290
  86. package/src/__tests__/runtime-classification.test.ts +0 -26
  87. package/src/__tests__/saas-identity-wire.integration.test.ts +0 -427
  88. package/src/__tests__/scaffold-app-feature.test.ts +0 -216
  89. package/src/__tests__/scaffold-app.test.ts +0 -630
  90. package/src/__tests__/scaffold-demo-tasks.test.ts +0 -47
  91. package/src/__tests__/scaffold-deploy.test.ts +0 -464
  92. package/src/__tests__/scaffold-dev-cycle.integration.test.ts +0 -109
  93. package/src/__tests__/scaffold-feature.test.ts +0 -143
  94. package/src/__tests__/schema-apply.integration.test.ts +0 -191
  95. package/src/__tests__/setup-test-stack-from-features.integration.test.ts +0 -30
  96. package/src/__tests__/setup-test-stack-job-run-logger.integration.test.ts +0 -72
  97. package/src/__tests__/walkthrough.integration.test.ts +0 -140
  98. package/src/__tests__/welcome-banner.test.ts +0 -42
  99. package/src/build-server-bundle.ts +0 -346
  100. package/src/build.ts +0 -28
  101. package/src/codegen/__tests__/render-codegen.test.ts +0 -100
  102. package/src/codegen/__tests__/run-codegen.test.ts +0 -665
  103. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +0 -347
  104. package/src/codegen/__tests__/watch.test.ts +0 -222
  105. package/src/codegen/index.ts +0 -19
  106. package/src/codegen/render.ts +0 -305
  107. package/src/codegen/run-codegen.ts +0 -278
  108. package/src/codegen/scan-events.ts +0 -565
  109. package/src/codegen/watch.ts +0 -140
  110. package/src/compose-stacks.ts +0 -193
  111. package/src/crash-tracker.ts +0 -56
  112. package/src/create-kumiko-server.ts +0 -1397
  113. package/src/env-schema.ts +0 -72
  114. package/src/few-shot-corpus.ts +0 -370
  115. package/src/framework-version.ts +0 -39
  116. package/src/index.ts +0 -105
  117. package/src/init-deploy-cli.ts +0 -148
  118. package/src/run-dev-app.ts +0 -694
  119. package/src/scaffold-app-feature.ts +0 -283
  120. package/src/scaffold-app.ts +0 -1033
  121. package/src/scaffold-deploy.ts +0 -393
  122. package/src/scaffold-feature.ts +0 -226
  123. package/src/schema-apply.ts +0 -123
  124. package/src/setup-test-stack-from-features.ts +0 -80
  125. package/src/welcome-banner.ts +0 -43
@@ -1,393 +0,0 @@
1
- // `kumiko init-deploy` scaffolding helper.
2
- //
3
- // Generates `deploy/Dockerfile`, `deploy/Dockerfile.dockerignore`, and
4
- // `deploy/migrate-step.sh` in the target app from canonical templates
5
- // shipped with @cosmicdrift/kumiko-dev-server. Substitutes `{{appName}}`,
6
- // `{{port}}`, `{{githubOrg}}`, `{{installManifests}}` placeholders and the
7
- // `hasSeeds`/`hasPrivateGhPackages`/`installFromFullTree`/
8
- // `installFromManifests` block flags detected from the app's source-tree.
9
- // `renderDeployFiles` is the pure computation (no writes); `scaffoldDeploy`
10
- // writes it to disk (refuses to overwrite existing files unless
11
- // `force: true`); `checkDeployDrift` compares it against what's on disk
12
- // without writing.
13
-
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
- import { dirname, join } from "node:path";
16
- import { fileURLToPath } from "node:url";
17
- import * as z from "zod";
18
- import { isKebabSegment } from "./kebab";
19
-
20
- export type RenderDeployFilesOptions = {
21
- /** App name, kebab-case (e.g. "publicstatus", "kumiko-studio"). */
22
- readonly appName: string;
23
- /** Container port the app listens on. Default 3000. */
24
- readonly port?: number;
25
- /** GitHub org for the published image-tag. Default "cosmicdriftgamestudio". */
26
- readonly githubOrg?: string;
27
- /** Destination directory (absolute or relative to cwd). The `deploy/`
28
- * subdir is created inside this. Default: process.cwd(). */
29
- readonly destination?: string;
30
- /** Source-tree root for optional-dir detection (seeds/, …). Defaults to
31
- * `destination`. Lets the caller scaffold into one dir while detecting
32
- * optional surfaces in another (rare — mostly destination = sourceDir). */
33
- readonly sourceDir?: string;
34
- };
35
-
36
- export type ScaffoldDeployOptions = RenderDeployFilesOptions & {
37
- /** Overwrite existing files instead of skipping them. */
38
- readonly force?: boolean;
39
- };
40
-
41
- const REGISTRY_CONFIG_FILES = ["bunfig.toml", ".npmrc"] as const;
42
- export type RegistryConfigFile = (typeof REGISTRY_CONFIG_FILES)[number];
43
-
44
- /** Detected optional-dirs/layout traits in the app's source-tree. Drives
45
- * which COPY blocks the Dockerfile-template emits. */
46
- export type ScaffoldDeployDetected = {
47
- /** ES-Operations seed-migrations (`seeds/`). Required for apps that
48
- * use the es-ops feature. */
49
- readonly hasSeeds: boolean;
50
- /** Private @cosmicdriftgamestudio/* GH-Packages → Dockerfile needs to
51
- * pass NPM_AUTH_TOKEN as build-arg + re-export it as $GITHUB_TOKEN inside
52
- * the build-stage (bunfig.toml/.npmrc read $GITHUB_TOKEN). */
53
- readonly hasPrivateGhPackages: boolean;
54
- /** A `workspaces` root, or a `file:`/`workspace:`/`link:` dependency spec
55
- * — bun needs the whole build context on disk to resolve the lockfile,
56
- * so the Dockerfile does `COPY . .` before `bun install` instead of
57
- * copying manifests first for layer-cache. */
58
- readonly installFromFullTree: boolean;
59
- /** Which of bunfig.toml/.npmrc exist at the source root — copied ahead of
60
- * `bun install` in the manifests-first install variant. */
61
- readonly registryConfigFiles: readonly RegistryConfigFile[];
62
- /** DB user for migrate-step.sh's DATABASE_URL — from
63
- * `package.json#kumiko.deploy.dbUser`, default = appName. The db name
64
- * always stays appName regardless of this value. */
65
- readonly dbUser: string;
66
- /** How migrate-step.sh locates the compose `_stack` network — from
67
- * `package.json#kumiko.deploy.stackNetwork`. "discover" (default):
68
- * today's `docker network ls` heuristic. "directory": the exact name
69
- * `$(basename "$PWD")_stack`, checked via `docker network inspect`. */
70
- readonly stackNetwork: "discover" | "directory";
71
- };
72
-
73
- export type ScaffoldedFile = {
74
- readonly path: string;
75
- readonly written: boolean;
76
- readonly reason?: "exists" | "force";
77
- };
78
-
79
- export type ScaffoldDeployResult = {
80
- readonly destination: string;
81
- readonly files: readonly ScaffoldedFile[];
82
- /** What scaffoldDeploy detected in the source-tree and used to gate
83
- * conditional Dockerfile blocks. Surfaced so the CLI can report it
84
- * ("hasSeeds=true → /app/seeds COPY emitted"). */
85
- readonly detected: ScaffoldDeployDetected;
86
- };
87
-
88
- export type RenderedDeployFile = {
89
- /** Template-relative output filename (e.g. "Dockerfile"). */
90
- readonly output: string;
91
- /** Absolute path this would be written to. */
92
- readonly path: string;
93
- readonly content: string;
94
- };
95
-
96
- export type RenderDeployFilesResult = {
97
- readonly deployDir: string;
98
- readonly files: readonly RenderedDeployFile[];
99
- readonly detected: ScaffoldDeployDetected;
100
- };
101
-
102
- export type DeployDriftEntry = {
103
- readonly path: string;
104
- readonly reason: "missing" | "differs";
105
- };
106
-
107
- export type CheckDeployDriftResult = {
108
- readonly drifted: readonly DeployDriftEntry[];
109
- readonly detected: ScaffoldDeployDetected;
110
- };
111
-
112
- const TEMPLATE_FILES = [
113
- { template: "Dockerfile.template", output: "Dockerfile" },
114
- {
115
- template: "Dockerfile.dockerignore.template",
116
- output: "Dockerfile.dockerignore",
117
- },
118
- { template: "migrate-step.sh.template", output: "migrate-step.sh" },
119
- ] as const;
120
-
121
- function templatesDir(): string {
122
- return join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "deploy");
123
- }
124
-
125
- /** Pure: computes the rendered deploy files for `options` without touching
126
- * disk beyond reading the app's source-tree (package.json, seeds/,
127
- * registry-config files) and the shipped templates. */
128
- export function renderDeployFiles(options: RenderDeployFilesOptions): RenderDeployFilesResult {
129
- if (!isKebabSegment(options.appName)) {
130
- throw new Error(
131
- `renderDeployFiles: appName must be kebab-case (a-z, 0-9, -); got "${options.appName}"`,
132
- );
133
- }
134
- const port = options.port ?? 3000;
135
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
136
- throw new Error(`renderDeployFiles: port must be 1..65535, got ${port}`);
137
- }
138
- const githubOrg = options.githubOrg ?? "cosmicdriftgamestudio";
139
- const destinationRoot = options.destination ?? process.cwd();
140
- const deployDir = join(destinationRoot, "deploy");
141
-
142
- // Detect optional surfaces in the source-tree so the Dockerfile only
143
- // emits COPYs for dirs that actually exist. Without this, apps without
144
- // a `seeds/` directory (e.g. studio) crash in Docker-build with
145
- // `failed to compute cache key: "/app/seeds": not found`.
146
- const sourceDir = options.sourceDir ?? destinationRoot;
147
- const detected = detectOptionalSurfaces(sourceDir, options.appName);
148
-
149
- const installManifests = ["package.json", "bun.lock", ...detected.registryConfigFiles].join(" ");
150
-
151
- const subs: Readonly<Record<string, string>> = {
152
- appName: options.appName,
153
- port: String(port),
154
- githubOrg,
155
- installManifests,
156
- dbUser: detected.dbUser,
157
- };
158
-
159
- const flags: Readonly<Record<string, boolean>> = {
160
- hasSeeds: detected.hasSeeds,
161
- hasPrivateGhPackages: detected.hasPrivateGhPackages,
162
- installFromFullTree: detected.installFromFullTree,
163
- installFromManifests: !detected.installFromFullTree,
164
- stackNetworkDiscover: detected.stackNetwork === "discover",
165
- stackNetworkDirectory: detected.stackNetwork === "directory",
166
- customDbUser: detected.dbUser !== options.appName,
167
- };
168
-
169
- const dir = templatesDir();
170
- const files: RenderedDeployFile[] = TEMPLATE_FILES.map(({ template, output }) => ({
171
- output,
172
- path: join(deployDir, output),
173
- content: render(readFileSync(join(dir, template), "utf-8"), subs, flags),
174
- }));
175
-
176
- return { deployDir, files, detected };
177
- }
178
-
179
- export function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployResult {
180
- const { deployDir, files: rendered, detected } = renderDeployFiles(options);
181
- mkdirSync(deployDir, { recursive: true });
182
-
183
- const files: ScaffoldedFile[] = [];
184
- for (const { path, content } of rendered) {
185
- const preExisted = existsSync(path);
186
- if (preExisted && !options.force) {
187
- files.push({ path, written: false, reason: "exists" });
188
- continue;
189
- }
190
- writeFileSync(path, content);
191
- // `reason: "force"` only when we actually clobbered a pre-existing
192
- // file — distinct from a clean first-time write. The existsSync above
193
- // is captured BEFORE the write so the flag reflects pre-state.
194
- files.push({
195
- path,
196
- written: true,
197
- ...(preExisted && options.force ? { reason: "force" as const } : {}),
198
- });
199
- }
200
-
201
- return { destination: deployDir, files, detected };
202
- }
203
-
204
- /** Read-only: compares the rendered deploy files against what's on disk
205
- * without writing anything. */
206
- export function checkDeployDrift(options: RenderDeployFilesOptions): CheckDeployDriftResult {
207
- const { files: rendered, detected } = renderDeployFiles(options);
208
- const drifted: DeployDriftEntry[] = [];
209
- for (const { path, content } of rendered) {
210
- if (!existsSync(path)) {
211
- drifted.push({ path, reason: "missing" });
212
- continue;
213
- }
214
- if (readFileSync(path, "utf-8") !== content) {
215
- drifted.push({ path, reason: "differs" });
216
- }
217
- }
218
- return { drifted, detected };
219
- }
220
-
221
- const packageJsonSchema = z.object({
222
- dependencies: z.record(z.string(), z.string()).optional(),
223
- devDependencies: z.record(z.string(), z.string()).optional(),
224
- workspaces: z
225
- .union([z.array(z.string()), z.object({ packages: z.array(z.string()).optional() })])
226
- .optional(),
227
- });
228
-
229
- function isRecord(value: unknown): value is Record<string, unknown> {
230
- return typeof value === "object" && value !== null;
231
- }
232
-
233
- /** Reads `raw.kumiko.deploy` without going through `packageJsonSchema` — an
234
- * unrelated shape problem elsewhere in package.json (e.g. a malformed
235
- * `dependencies`) must not silently drop a valid deploy config; only
236
- * actually-malformed JSON (JSON.parse throwing) should fall back. */
237
- function extractDeployConfigRaw(raw: unknown): unknown {
238
- if (!isRecord(raw)) return undefined;
239
- const kumiko = raw["kumiko"];
240
- if (!isRecord(kumiko)) return undefined;
241
- return kumiko["deploy"];
242
- }
243
-
244
- const LOCAL_DEP_SPEC_RE = /^(file:|workspace:|link:)/;
245
-
246
- // The user part of DATABASE_URL — deliberately stricter than a full Postgres
247
- // role-name grammar (no quoting support) since it is interpolated into a
248
- // shell string, not passed through a driver's escaping.
249
- const DB_USER_RE = /^[a-zA-Z0-9_][a-zA-Z0-9_-]{0,62}$/;
250
-
251
- const kumikoDeployConfigSchema = z
252
- .object({
253
- dbUser: z.string().regex(DB_USER_RE).optional(),
254
- stackNetwork: z.enum(["discover", "directory"]).optional(),
255
- })
256
- .strict();
257
-
258
- type KumikoDeployConfig = z.infer<typeof kumikoDeployConfigSchema>;
259
-
260
- /** `package.json#kumiko.deploy` must be well-formed — silently falling back
261
- * to defaults on an invalid `dbUser`/`stackNetwork` would render a migrate
262
- * step that talks to the wrong DB user or the wrong stack network, breaking
263
- * prod migrations without anyone noticing. Unlike detectOptionalSurfaces'
264
- * malformed-JSON fallback, this throws (fail loud) and names the field. */
265
- function parseDeployConfig(raw: unknown): KumikoDeployConfig {
266
- if (raw === undefined) return {};
267
- const result = kumikoDeployConfigSchema.safeParse(raw);
268
- if (result.success) return result.data;
269
- const issue = result.error.issues[0];
270
- const location =
271
- issue === undefined
272
- ? "kumiko.deploy"
273
- : issue.code === "unrecognized_keys"
274
- ? ["kumiko", "deploy", ...issue.keys].join(".")
275
- : ["kumiko", "deploy", ...issue.path.map(String)].join(".");
276
- throw new Error(
277
- `scaffoldDeploy: invalid package.json#${location} — ${issue?.message ?? "validation failed"}`,
278
- );
279
- }
280
-
281
- function resolveDeployConfig(
282
- deployConfigRaw: unknown,
283
- appName: string,
284
- ): Pick<ScaffoldDeployDetected, "dbUser" | "stackNetwork"> {
285
- const config = parseDeployConfig(deployConfigRaw);
286
- const dbUser = config.dbUser ?? appName;
287
- // The default (appName) already passed isKebabSegment's charset check but
288
- // not DB_USER_RE's length cap — validate the effective value so an
289
- // over-long appName fails loud here instead of producing a DB user
290
- // Postgres itself would reject at migrate-time.
291
- if (!DB_USER_RE.test(dbUser)) {
292
- throw new Error(
293
- `scaffoldDeploy: invalid package.json#kumiko.deploy.dbUser — effective value "${dbUser}" (defaulted from appName) does not match ${DB_USER_RE}`,
294
- );
295
- }
296
- return { dbUser, stackNetwork: config.stackNetwork ?? "discover" };
297
- }
298
-
299
- function detectOptionalSurfaces(sourceDir: string, appName: string): ScaffoldDeployDetected {
300
- const hasSeeds = existsSync(join(sourceDir, "seeds"));
301
- let hasPrivateGhPackages = false;
302
- let installFromFullTree = false;
303
- let deployConfigRaw: unknown;
304
- const pkgJsonPath = join(sourceDir, "package.json");
305
- if (existsSync(pkgJsonPath)) {
306
- try {
307
- const raw: unknown = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
308
- // Read ahead of packageJsonSchema.parse: an unrelated shape problem in
309
- // `dependencies`/`workspaces` must not silently discard a valid deploy
310
- // config along with it (that's caught by the outer catch below).
311
- deployConfigRaw = extractDeployConfigRaw(raw);
312
- const pkg = packageJsonSchema.parse(raw);
313
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
314
- hasPrivateGhPackages = Object.keys(allDeps).some((d) =>
315
- d.startsWith("@cosmicdriftgamestudio/"),
316
- );
317
- installFromFullTree =
318
- pkg.workspaces !== undefined ||
319
- Object.values(allDeps).some((v) => LOCAL_DEP_SPEC_RE.test(v));
320
- } catch (err) {
321
- // malformed JSON (or unexpected dependencies/workspaces shape) —
322
- // assume no private packages and a plain manifests-first install;
323
- // app-author can override via Dockerfile. Warn so a silent
324
- // mis-detection (later YN0041 on yarn install, or a broken lockfile
325
- // resolve) is traceable to the scaffold step. If JSON.parse itself
326
- // failed, deployConfigRaw also stays undefined → deploy params fall
327
- // back to defaults (an invalid deploy config must THROW, but
328
- // unreadable JSON can't distinguish "no deploy config" from "invalid
329
- // deploy config" — it already surfaces via this warning).
330
- // biome-ignore lint/suspicious/noConsole: scaffold visibility for skipped private-package detection
331
- console.warn(
332
- `scaffoldDeploy: package.json at ${pkgJsonPath} is not valid JSON — private-GH-packages/install-layout detection skipped (${err instanceof Error ? err.message : String(err)})`,
333
- );
334
- }
335
- }
336
- const registryConfigFiles = REGISTRY_CONFIG_FILES.filter((f) => existsSync(join(sourceDir, f)));
337
- return {
338
- hasSeeds,
339
- hasPrivateGhPackages,
340
- installFromFullTree,
341
- registryConfigFiles,
342
- ...resolveDeployConfig(deployConfigRaw, appName),
343
- };
344
- }
345
-
346
- // Unconsumed-mustache guard. After step 1+2 the only legitimate `{{`
347
- // survivors are Docker/Go template tags (`{{.Name}}`, leading `.`). An
348
- // orphan close-tag (`{{/foo}}` without an opener) matches neither step and
349
- // would otherwise leak into the rendered file as a silent failure.
350
- const ORPHAN_MUSTACHE_RE = /\{\{(?!\.)[^}]*\}\}/;
351
-
352
- export function render(
353
- source: string,
354
- subs: Readonly<Record<string, string>>,
355
- flags: Readonly<Record<string, boolean>>,
356
- ): string {
357
- // Step 1: handle mustache-style block conditionals `{{#flag}}...{{/flag}}`
358
- // (multiline-aware via [\s\S]). When flag is truthy → keep inner content;
359
- // when falsy → strip the entire block (including the surrounding line so
360
- // we don't leave blank lines in the rendered Dockerfile).
361
- let result = source.replace(
362
- /^[ \t]*\{\{#([a-z][a-zA-Z0-9]*)\}\}\n([\s\S]*?)\n[ \t]*\{\{\/\1\}\}[ \t]*\n?/gm,
363
- (_full, key: string, inner: string) => {
364
- const flag = flags[key];
365
- if (flag === undefined) {
366
- throw new Error(`scaffoldDeploy.render: unknown block-flag "{{#${key}}}"`);
367
- }
368
- return flag ? `${inner}\n` : "";
369
- },
370
- );
371
-
372
- // Step 2: handle plain `{{key}}` substitutions. Pattern is intentionally
373
- // narrow: lowercase-leading identifier followed by alphanumerics. That
374
- // excludes Docker/Go template syntax like `{{.Name}}` (leading `.`)
375
- // which appears verbatim in the migrate-step.sh shell snippet.
376
- result = result.replace(/\{\{([a-z][a-zA-Z0-9]*)\}\}/g, (full, key: string) => {
377
- const value = subs[key];
378
- if (value === undefined) {
379
- throw new Error(`scaffoldDeploy.render: unknown placeholder "${full}"`);
380
- }
381
- return value;
382
- });
383
-
384
- const orphan = ORPHAN_MUSTACHE_RE.exec(result);
385
- if (orphan) {
386
- throw new Error(
387
- `scaffoldDeploy.render: unconsumed mustache tag "${orphan[0]}" — ` +
388
- `likely an orphan close-tag (e.g. a stray "{{/flag}}") in the template.`,
389
- );
390
- }
391
-
392
- return result;
393
- }
@@ -1,226 +0,0 @@
1
- // scaffoldFeature — generate a fresh feature workspace from a name.
2
- // Used by `bun kumiko create <name>` and (later) by the Designer when
3
- // a tenant scaffolds a new feature inside their repo. Wraps the
4
- // canonical-form renderer (feature-ast/render.ts) so every freshly
5
- // scaffolded feature is born in canonical Object-Form with the
6
- // schema-version header set.
7
- //
8
- // The generated workspace is intentionally minimal: a single entity
9
- // pattern as a starter, so the user has something to point a "bun
10
- // kumiko dev" at and immediately see something on screen. Adding more
11
- // patterns is the user's job (or the Designer's / AI's, on top of this
12
- // scaffolding).
13
-
14
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
15
- import { join, resolve } from "node:path";
16
- import {
17
- type FeaturePattern,
18
- renderFeatureFile,
19
- type SourceLocation,
20
- } from "@cosmicdrift/kumiko-framework/engine";
21
-
22
- // =============================================================================
23
- // Public API
24
- // =============================================================================
25
-
26
- export type ScaffoldFeatureOptions = {
27
- /** camelCase feature name. Must be a valid JS identifier. */
28
- readonly name: string;
29
- /**
30
- * Absolute or repo-relative path where the feature workspace gets
31
- * created. Defaults to `samples/recipes/<kebab-name>/` under the
32
- * resolved repo root.
33
- */
34
- readonly destination?: string;
35
- /** Repo root used to resolve the default destination. Defaults to cwd. */
36
- readonly repoRoot?: string;
37
- };
38
-
39
- export type ScaffoldFeatureResult = {
40
- readonly destination: string;
41
- readonly featureFile: string;
42
- readonly packageJsonFile: string;
43
- readonly tsconfigFile: string;
44
- readonly featureName: string;
45
- readonly packageName: string;
46
- };
47
-
48
- /**
49
- * Generate a starter feature workspace at `destination`. Throws when
50
- * the destination already exists — refuses to overwrite. The caller is
51
- * expected to run `bun install` afterwards to wire the workspace.
52
- */
53
- export function scaffoldFeature(options: ScaffoldFeatureOptions): ScaffoldFeatureResult {
54
- const featureName = validateFeatureName(options.name);
55
- const repoRoot = options.repoRoot ?? process.cwd();
56
- const kebab = camelToKebab(featureName);
57
- const destination = resolve(
58
- options.destination
59
- ? resolveDestination(options.destination, repoRoot)
60
- : join(repoRoot, "samples", "recipes", kebab),
61
- );
62
-
63
- if (existsSync(destination)) {
64
- throw new Error(
65
- `scaffoldFeature: destination already exists at ${destination} — refusing to overwrite`,
66
- );
67
- }
68
-
69
- mkdirSync(join(destination, "src"), { recursive: true });
70
-
71
- const packageName = `@cosmicdrift/kumiko-sample-${kebab}`;
72
- const packageJson = renderPackageJson(packageName);
73
- const packageJsonFile = join(destination, "package.json");
74
- writeFileSync(packageJsonFile, packageJson);
75
-
76
- const tsconfigFile = join(destination, "tsconfig.json");
77
- writeFileSync(tsconfigFile, renderTsconfig());
78
-
79
- const featureFile = join(destination, "src", "feature.ts");
80
- const featureSource = renderFeatureFile({
81
- featureName,
82
- patterns: starterPatterns(),
83
- });
84
- writeFileSync(featureFile, featureSource);
85
-
86
- return {
87
- destination,
88
- featureFile,
89
- packageJsonFile,
90
- tsconfigFile,
91
- featureName,
92
- packageName,
93
- };
94
- }
95
-
96
- // =============================================================================
97
- // Internal — name + path validation
98
- // =============================================================================
99
-
100
- const RESERVED_WORDS: ReadonlySet<string> = new Set([
101
- // Subset of TS reserved words that would be confusing as feature names.
102
- "default",
103
- "delete",
104
- "function",
105
- "import",
106
- "export",
107
- "class",
108
- "interface",
109
- "enum",
110
- "type",
111
- "module",
112
- "package",
113
- "private",
114
- "protected",
115
- "public",
116
- "static",
117
- "void",
118
- "null",
119
- "true",
120
- "false",
121
- ]);
122
-
123
- const NAME_RE = /^[a-z][A-Za-z0-9]*$/;
124
-
125
- function validateFeatureName(raw: string): string {
126
- if (!raw) {
127
- throw new Error("scaffoldFeature: feature name is required");
128
- }
129
- if (!NAME_RE.test(raw)) {
130
- throw new Error(
131
- `scaffoldFeature: "${raw}" is not a valid feature name — use camelCase starting with a lowercase letter (e.g. "todoList")`,
132
- );
133
- }
134
- if (RESERVED_WORDS.has(raw)) {
135
- throw new Error(`scaffoldFeature: "${raw}" is a reserved word and cannot be a feature name`);
136
- }
137
- return raw;
138
- }
139
-
140
- function camelToKebab(name: string): string {
141
- return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
142
- }
143
-
144
- function resolveDestination(dest: string, repoRoot: string): string {
145
- // Allow callers to pass either an absolute path or a repo-relative
146
- // one — keep both ergonomic. `resolve(repoRoot, dest)` is a no-op for
147
- // absolute paths.
148
- return resolve(repoRoot, dest);
149
- }
150
-
151
- // =============================================================================
152
- // Internal — content generators
153
- // =============================================================================
154
-
155
- function renderPackageJson(packageName: string): string {
156
- return `${JSON.stringify(
157
- {
158
- name: packageName,
159
- description: "Kumiko sample feature — scaffolded by `bun kumiko create`",
160
- private: true,
161
- dependencies: {
162
- "@cosmicdrift/kumiko-framework": "workspace:*",
163
- },
164
- },
165
- null,
166
- 2,
167
- )}\n`;
168
- }
169
-
170
- /**
171
- * Standard tsconfig matching the rest of the sample workspaces:
172
- * strict, ESNext, bundler-resolution, no-emit. Without this file
173
- * `bun install + tsc` immediately complains about missing config —
174
- * scaffolded features should compile cleanly out of the box.
175
- */
176
- function renderTsconfig(): string {
177
- return `${JSON.stringify(
178
- {
179
- compilerOptions: {
180
- strict: true,
181
- noUncheckedIndexedAccess: true,
182
- noPropertyAccessFromIndexSignature: true,
183
- forceConsistentCasingInFileNames: true,
184
- verbatimModuleSyntax: true,
185
- target: "ESNext",
186
- module: "ESNext",
187
- moduleResolution: "bundler",
188
- esModuleInterop: true,
189
- skipLibCheck: true,
190
- lib: ["ESNext"],
191
- types: ["bun-types"],
192
- noEmit: true,
193
- },
194
- include: ["src/**/*"],
195
- },
196
- null,
197
- 2,
198
- )}\n`;
199
- }
200
-
201
- // Synthetic SourceLocation — the renderer reads `.raw` only for opaque
202
- // (closure-bearing) bodies. Static patterns like `entity` don't touch
203
- // `source.raw` at render-time, so an empty placeholder is fine.
204
- const SYNTHETIC_LOC: SourceLocation = {
205
- file: "<scaffold>",
206
- start: { line: 1, column: 1 },
207
- end: { line: 1, column: 1 },
208
- raw: "",
209
- };
210
-
211
- function starterPatterns(): readonly FeaturePattern[] {
212
- // One entity, one field. Smallest interesting output: parses, renders,
213
- // can be `bun kumiko dev`'d, and gives the user something to extend.
214
- return [
215
- {
216
- kind: "entity",
217
- source: SYNTHETIC_LOC,
218
- entityName: "item",
219
- definition: {
220
- fields: {
221
- title: { type: "text", required: true },
222
- },
223
- },
224
- },
225
- ];
226
- }