@cosmicdrift/kumiko-dev-server 0.329.0 → 0.330.2

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
@@ -0,0 +1,8 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ /** Canonical demo feature — keep in sync with `renderDemoTasksFeatureFile()`. */
3
+ export declare function createDemoTasksFeature(): FeatureDefinition;
4
+ export declare function renderDemoTasksFeatureFile(): string;
5
+ export declare function renderDemoTasksI18n(): string;
6
+ export declare function renderDemoTasksWebIndex(): string;
7
+ export declare function renderDemoTasksIndex(): string;
8
+ export declare function renderDemoSeedFile(): string;
@@ -1,102 +1,77 @@
1
1
  // Demo `tasks` feature + seed for scaffolded apps — wasp-like starter UX.
2
2
  // `createDemoTasksFeature()` feeds init-migration generation; the render*
3
3
  // functions emit the same shape into the user's repo.
4
-
5
- import {
6
- createBooleanField,
7
- createEntity,
8
- createNumberField,
9
- createTextField,
10
- defineEntityCreateHandler,
11
- defineEntityDeleteHandler,
12
- defineEntityDetailHandler,
13
- defineEntityListHandler,
14
- defineEntityUpdateHandler,
15
- defineFeature,
16
- type FeatureDefinition,
17
- } from "@cosmicdrift/kumiko-framework/engine";
18
- import type {
19
- EntityEditScreenDefinition,
20
- EntityListScreenDefinition,
21
- } from "@cosmicdrift/kumiko-framework/ui-types";
22
-
4
+ import { createBooleanField, createEntity, createNumberField, createTextField, defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
23
5
  const taskEntity = createEntity({
24
- fields: {
25
- title: createTextField({
26
- required: true,
27
- sortable: true,
28
- personal: false,
29
- reason: "is_business_data",
30
- }),
31
- status: createTextField({ sortable: true, personal: false, reason: "is_business_data" }),
32
- priority: createNumberField(),
33
- isUrgent: createBooleanField({ default: false }),
34
- },
6
+ fields: {
7
+ title: createTextField({
8
+ required: true,
9
+ sortable: true,
10
+ personal: false,
11
+ reason: "is_business_data",
12
+ }),
13
+ status: createTextField({ sortable: true, personal: false, reason: "is_business_data" }),
14
+ priority: createNumberField(),
15
+ isUrgent: createBooleanField({ default: false }),
16
+ },
35
17
  });
36
-
37
- const listScreen: EntityListScreenDefinition = {
38
- id: "task-list",
39
- type: "entityList",
40
- entity: "task",
41
- columns: ["title", "status", "isUrgent", "priority"],
42
- defaultSort: { field: "title", dir: "asc" },
43
- rowActions: [{ kind: "navigate", id: "edit", label: "Edit", screen: "task-edit" }],
18
+ const listScreen = {
19
+ id: "task-list",
20
+ type: "entityList",
21
+ entity: "task",
22
+ columns: ["title", "status", "isUrgent", "priority"],
23
+ defaultSort: { field: "title", dir: "asc" },
24
+ rowActions: [{ kind: "navigate", id: "edit", label: "Edit", screen: "task-edit" }],
44
25
  };
45
-
46
- const editScreen: EntityEditScreenDefinition = {
47
- id: "task-edit",
48
- type: "entityEdit",
49
- entity: "task",
50
- layout: {
51
- sections: [{ title: "Task", fields: ["title", "status", "priority", "isUrgent"] }],
52
- },
26
+ const editScreen = {
27
+ id: "task-edit",
28
+ type: "entityEdit",
29
+ entity: "task",
30
+ layout: {
31
+ sections: [{ title: "Task", fields: ["title", "status", "priority", "isUrgent"] }],
32
+ },
53
33
  };
54
-
55
34
  const open = {
56
- access: {
57
- openToAll: {
58
- reason:
59
- "scaffolded demo handler: every signed-in user of the demo tenant manages their tasks",
35
+ access: {
36
+ openToAll: {
37
+ reason: "scaffolded demo handler: every signed-in user of the demo tenant manages their tasks",
38
+ },
60
39
  },
61
- },
62
- } as const;
63
-
40
+ };
64
41
  const TASKS_I18N = {
65
- "screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
66
- "screen:task-edit.title": { de: "Aufgabe", en: "Task" },
67
- "tasks:nav.tasks": { de: "Aufgaben", en: "Tasks" },
68
- "tasks:nav.taskNew": { de: "Neue Aufgabe", en: "New task" },
69
- "tasks:entity:task:field:title": { de: "Titel", en: "Title" },
70
- "tasks:entity:task:field:status": { de: "Status", en: "Status" },
71
- "tasks:entity:task:field:priority": { de: "Priorität", en: "Priority" },
72
- "tasks:entity:task:field:isUrgent": { de: "Dringend", en: "Urgent" },
73
- } as const;
74
-
42
+ "screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
43
+ "screen:task-edit.title": { de: "Aufgabe", en: "Task" },
44
+ "tasks:nav.tasks": { de: "Aufgaben", en: "Tasks" },
45
+ "tasks:nav.taskNew": { de: "Neue Aufgabe", en: "New task" },
46
+ "tasks:entity:task:field:title": { de: "Titel", en: "Title" },
47
+ "tasks:entity:task:field:status": { de: "Status", en: "Status" },
48
+ "tasks:entity:task:field:priority": { de: "Priorität", en: "Priority" },
49
+ "tasks:entity:task:field:isUrgent": { de: "Dringend", en: "Urgent" },
50
+ };
75
51
  /** Canonical demo feature — keep in sync with `renderDemoTasksFeatureFile()`. */
76
- export function createDemoTasksFeature(): FeatureDefinition {
77
- return defineFeature("tasks", (r) => {
78
- r.translations({ keys: TASKS_I18N });
79
- r.entity("task", taskEntity);
80
- r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
81
- r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
82
- r.writeHandler(defineEntityDeleteHandler("task", taskEntity, open));
83
- r.queryHandler(defineEntityListHandler("task", taskEntity, open));
84
- r.queryHandler(defineEntityDetailHandler("task", taskEntity, open));
85
- r.screen(listScreen);
86
- r.screen(editScreen);
87
- r.nav({ id: "tasks", label: "tasks:nav.tasks", order: 10, screen: "tasks:screen:task-list" });
88
- r.nav({
89
- id: "task-new",
90
- label: "tasks:nav.taskNew",
91
- parent: "tasks:nav:tasks",
92
- screen: "tasks:screen:task-edit",
93
- order: 10,
52
+ export function createDemoTasksFeature() {
53
+ return defineFeature("tasks", (r) => {
54
+ r.translations({ keys: TASKS_I18N });
55
+ r.entity("task", taskEntity);
56
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
57
+ r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
58
+ r.writeHandler(defineEntityDeleteHandler("task", taskEntity, open));
59
+ r.queryHandler(defineEntityListHandler("task", taskEntity, open));
60
+ r.queryHandler(defineEntityDetailHandler("task", taskEntity, open));
61
+ r.screen(listScreen);
62
+ r.screen(editScreen);
63
+ r.nav({ id: "tasks", label: "tasks:nav.tasks", order: 10, screen: "tasks:screen:task-list" });
64
+ r.nav({
65
+ id: "task-new",
66
+ label: "tasks:nav.taskNew",
67
+ parent: "tasks:nav:tasks",
68
+ screen: "tasks:screen:task-edit",
69
+ order: 10,
70
+ });
94
71
  });
95
- });
96
72
  }
97
-
98
- export function renderDemoTasksFeatureFile(): string {
99
- return `// Demo tasks feature — scaffolded by \`kumiko new app\`. Edit or replace.
73
+ export function renderDemoTasksFeatureFile() {
74
+ return `// Demo tasks feature — scaffolded by \`kumiko new app\`. Edit or replace.
100
75
  // Entity + CRUD handlers + list/edit screens + sidebar nav.
101
76
 
102
77
  import {
@@ -178,9 +153,8 @@ export const tasksFeature = defineFeature("tasks", (r) => {
178
153
  });
179
154
  `;
180
155
  }
181
-
182
- export function renderDemoTasksI18n(): string {
183
- return `// Server-side translation keys for the demo tasks feature.
156
+ export function renderDemoTasksI18n() {
157
+ return `// Server-side translation keys for the demo tasks feature.
184
158
 
185
159
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
186
160
 
@@ -219,9 +193,8 @@ export const tasksTranslations: TranslationsByLocale = {
219
193
  };
220
194
  `;
221
195
  }
222
-
223
- export function renderDemoTasksWebIndex(): string {
224
- return `import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
196
+ export function renderDemoTasksWebIndex() {
197
+ return `import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
225
198
  import { tasksTranslations } from "../i18n";
226
199
 
227
200
  export const tasksClient: ClientFeatureDefinition = {
@@ -230,14 +203,12 @@ export const tasksClient: ClientFeatureDefinition = {
230
203
  };
231
204
  `;
232
205
  }
233
-
234
- export function renderDemoTasksIndex(): string {
235
- return `export { tasksFeature } from "./feature";
206
+ export function renderDemoTasksIndex() {
207
+ return `export { tasksFeature } from "./feature";
236
208
  `;
237
209
  }
238
-
239
- export function renderDemoSeedFile(): string {
240
- return `// Demo seed — a few tasks so \`bun dev\` shows a non-empty list.
210
+ export function renderDemoSeedFile() {
211
+ return `// Demo seed — a few tasks so \`bun dev\` shows a non-empty list.
241
212
 
242
213
  import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
243
214
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
@@ -0,0 +1,92 @@
1
+ export type RenderDeployFilesOptions = {
2
+ /** App name, kebab-case (e.g. "publicstatus", "kumiko-studio"). */
3
+ readonly appName: string;
4
+ /** Container port the app listens on. Default 3000. */
5
+ readonly port?: number;
6
+ /** GitHub org for the published image-tag. Default "cosmicdriftgamestudio". */
7
+ readonly githubOrg?: string;
8
+ /** Destination directory (absolute or relative to cwd). The `deploy/`
9
+ * subdir is created inside this. Default: process.cwd(). */
10
+ readonly destination?: string;
11
+ /** Source-tree root for optional-dir detection (seeds/, …). Defaults to
12
+ * `destination`. Lets the caller scaffold into one dir while detecting
13
+ * optional surfaces in another (rare — mostly destination = sourceDir). */
14
+ readonly sourceDir?: string;
15
+ };
16
+ export type ScaffoldDeployOptions = RenderDeployFilesOptions & {
17
+ /** Overwrite existing files instead of skipping them. */
18
+ readonly force?: boolean;
19
+ };
20
+ declare const REGISTRY_CONFIG_FILES: readonly ["bunfig.toml", ".npmrc"];
21
+ export type RegistryConfigFile = (typeof REGISTRY_CONFIG_FILES)[number];
22
+ /** Detected optional-dirs/layout traits in the app's source-tree. Drives
23
+ * which COPY blocks the Dockerfile-template emits. */
24
+ export type ScaffoldDeployDetected = {
25
+ /** ES-Operations seed-migrations (`seeds/`). Required for apps that
26
+ * use the es-ops feature. */
27
+ readonly hasSeeds: boolean;
28
+ /** Private @cosmicdriftgamestudio/* GH-Packages → Dockerfile needs to
29
+ * pass NPM_AUTH_TOKEN as build-arg + re-export it as $GITHUB_TOKEN inside
30
+ * the build-stage (bunfig.toml/.npmrc read $GITHUB_TOKEN). */
31
+ readonly hasPrivateGhPackages: boolean;
32
+ /** A `workspaces` root, or a `file:`/`workspace:`/`link:` dependency spec
33
+ * — bun needs the whole build context on disk to resolve the lockfile,
34
+ * so the Dockerfile does `COPY . .` before `bun install` instead of
35
+ * copying manifests first for layer-cache. */
36
+ readonly installFromFullTree: boolean;
37
+ /** Which of bunfig.toml/.npmrc exist at the source root — copied ahead of
38
+ * `bun install` in the manifests-first install variant. */
39
+ readonly registryConfigFiles: readonly RegistryConfigFile[];
40
+ /** DB user for migrate-step.sh's DATABASE_URL — from
41
+ * `package.json#kumiko.deploy.dbUser`, default = appName. The db name
42
+ * always stays appName regardless of this value. */
43
+ readonly dbUser: string;
44
+ /** How migrate-step.sh locates the compose `_stack` network — from
45
+ * `package.json#kumiko.deploy.stackNetwork`. "discover" (default):
46
+ * today's `docker network ls` heuristic. "directory": the exact name
47
+ * `$(basename "$PWD")_stack`, checked via `docker network inspect`. */
48
+ readonly stackNetwork: "discover" | "directory";
49
+ };
50
+ export type ScaffoldedFile = {
51
+ readonly path: string;
52
+ readonly written: boolean;
53
+ readonly reason?: "exists" | "force";
54
+ };
55
+ export type ScaffoldDeployResult = {
56
+ readonly destination: string;
57
+ readonly files: readonly ScaffoldedFile[];
58
+ /** What scaffoldDeploy detected in the source-tree and used to gate
59
+ * conditional Dockerfile blocks. Surfaced so the CLI can report it
60
+ * ("hasSeeds=true → /app/seeds COPY emitted"). */
61
+ readonly detected: ScaffoldDeployDetected;
62
+ };
63
+ export type RenderedDeployFile = {
64
+ /** Template-relative output filename (e.g. "Dockerfile"). */
65
+ readonly output: string;
66
+ /** Absolute path this would be written to. */
67
+ readonly path: string;
68
+ readonly content: string;
69
+ };
70
+ export type RenderDeployFilesResult = {
71
+ readonly deployDir: string;
72
+ readonly files: readonly RenderedDeployFile[];
73
+ readonly detected: ScaffoldDeployDetected;
74
+ };
75
+ export type DeployDriftEntry = {
76
+ readonly path: string;
77
+ readonly reason: "missing" | "differs";
78
+ };
79
+ export type CheckDeployDriftResult = {
80
+ readonly drifted: readonly DeployDriftEntry[];
81
+ readonly detected: ScaffoldDeployDetected;
82
+ };
83
+ /** Pure: computes the rendered deploy files for `options` without touching
84
+ * disk beyond reading the app's source-tree (package.json, seeds/,
85
+ * registry-config files) and the shipped templates. */
86
+ export declare function renderDeployFiles(options: RenderDeployFilesOptions): RenderDeployFilesResult;
87
+ export declare function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployResult;
88
+ /** Read-only: compares the rendered deploy files against what's on disk
89
+ * without writing anything. */
90
+ export declare function checkDeployDrift(options: RenderDeployFilesOptions): CheckDeployDriftResult;
91
+ export declare function render(source: string, subs: Readonly<Record<string, string>>, flags: Readonly<Record<string, boolean>>): string;
92
+ export {};
@@ -0,0 +1,254 @@
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
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import * as z from "zod";
17
+ import { isKebabSegment } from "./kebab.js";
18
+ const REGISTRY_CONFIG_FILES = ["bunfig.toml", ".npmrc"];
19
+ const TEMPLATE_FILES = [
20
+ { template: "Dockerfile.template", output: "Dockerfile" },
21
+ {
22
+ template: "Dockerfile.dockerignore.template",
23
+ output: "Dockerfile.dockerignore",
24
+ },
25
+ { template: "migrate-step.sh.template", output: "migrate-step.sh" },
26
+ ];
27
+ function templatesDir() {
28
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "deploy");
29
+ }
30
+ /** Pure: computes the rendered deploy files for `options` without touching
31
+ * disk beyond reading the app's source-tree (package.json, seeds/,
32
+ * registry-config files) and the shipped templates. */
33
+ export function renderDeployFiles(options) {
34
+ if (!isKebabSegment(options.appName)) {
35
+ throw new Error(`renderDeployFiles: appName must be kebab-case (a-z, 0-9, -); got "${options.appName}"`);
36
+ }
37
+ const port = options.port ?? 3000;
38
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
39
+ throw new Error(`renderDeployFiles: port must be 1..65535, got ${port}`);
40
+ }
41
+ const githubOrg = options.githubOrg ?? "cosmicdriftgamestudio";
42
+ const destinationRoot = options.destination ?? process.cwd();
43
+ const deployDir = join(destinationRoot, "deploy");
44
+ // Detect optional surfaces in the source-tree so the Dockerfile only
45
+ // emits COPYs for dirs that actually exist. Without this, apps without
46
+ // a `seeds/` directory (e.g. studio) crash in Docker-build with
47
+ // `failed to compute cache key: "/app/seeds": not found`.
48
+ const sourceDir = options.sourceDir ?? destinationRoot;
49
+ const detected = detectOptionalSurfaces(sourceDir, options.appName);
50
+ const installManifests = ["package.json", "bun.lock", ...detected.registryConfigFiles].join(" ");
51
+ const subs = {
52
+ appName: options.appName,
53
+ port: String(port),
54
+ githubOrg,
55
+ installManifests,
56
+ dbUser: detected.dbUser,
57
+ };
58
+ const flags = {
59
+ hasSeeds: detected.hasSeeds,
60
+ hasPrivateGhPackages: detected.hasPrivateGhPackages,
61
+ installFromFullTree: detected.installFromFullTree,
62
+ installFromManifests: !detected.installFromFullTree,
63
+ stackNetworkDiscover: detected.stackNetwork === "discover",
64
+ stackNetworkDirectory: detected.stackNetwork === "directory",
65
+ customDbUser: detected.dbUser !== options.appName,
66
+ };
67
+ const dir = templatesDir();
68
+ const files = TEMPLATE_FILES.map(({ template, output }) => ({
69
+ output,
70
+ path: join(deployDir, output),
71
+ content: render(readFileSync(join(dir, template), "utf-8"), subs, flags),
72
+ }));
73
+ return { deployDir, files, detected };
74
+ }
75
+ export function scaffoldDeploy(options) {
76
+ const { deployDir, files: rendered, detected } = renderDeployFiles(options);
77
+ mkdirSync(deployDir, { recursive: true });
78
+ const files = [];
79
+ for (const { path, content } of rendered) {
80
+ const preExisted = existsSync(path);
81
+ if (preExisted && !options.force) {
82
+ files.push({ path, written: false, reason: "exists" });
83
+ continue;
84
+ }
85
+ writeFileSync(path, content);
86
+ // `reason: "force"` only when we actually clobbered a pre-existing
87
+ // file — distinct from a clean first-time write. The existsSync above
88
+ // is captured BEFORE the write so the flag reflects pre-state.
89
+ files.push({
90
+ path,
91
+ written: true,
92
+ ...(preExisted && options.force ? { reason: "force" } : {}),
93
+ });
94
+ }
95
+ return { destination: deployDir, files, detected };
96
+ }
97
+ /** Read-only: compares the rendered deploy files against what's on disk
98
+ * without writing anything. */
99
+ export function checkDeployDrift(options) {
100
+ const { files: rendered, detected } = renderDeployFiles(options);
101
+ const drifted = [];
102
+ for (const { path, content } of rendered) {
103
+ if (!existsSync(path)) {
104
+ drifted.push({ path, reason: "missing" });
105
+ continue;
106
+ }
107
+ if (readFileSync(path, "utf-8") !== content) {
108
+ drifted.push({ path, reason: "differs" });
109
+ }
110
+ }
111
+ return { drifted, detected };
112
+ }
113
+ const packageJsonSchema = z.object({
114
+ dependencies: z.record(z.string(), z.string()).optional(),
115
+ devDependencies: z.record(z.string(), z.string()).optional(),
116
+ workspaces: z
117
+ .union([z.array(z.string()), z.object({ packages: z.array(z.string()).optional() })])
118
+ .optional(),
119
+ });
120
+ function isRecord(value) {
121
+ return typeof value === "object" && value !== null;
122
+ }
123
+ /** Reads `raw.kumiko.deploy` without going through `packageJsonSchema` — an
124
+ * unrelated shape problem elsewhere in package.json (e.g. a malformed
125
+ * `dependencies`) must not silently drop a valid deploy config; only
126
+ * actually-malformed JSON (JSON.parse throwing) should fall back. */
127
+ function extractDeployConfigRaw(raw) {
128
+ if (!isRecord(raw))
129
+ return undefined;
130
+ const kumiko = raw["kumiko"];
131
+ if (!isRecord(kumiko))
132
+ return undefined;
133
+ return kumiko["deploy"];
134
+ }
135
+ const LOCAL_DEP_SPEC_RE = /^(file:|workspace:|link:)/;
136
+ // The user part of DATABASE_URL — deliberately stricter than a full Postgres
137
+ // role-name grammar (no quoting support) since it is interpolated into a
138
+ // shell string, not passed through a driver's escaping.
139
+ const DB_USER_RE = /^[a-zA-Z0-9_][a-zA-Z0-9_-]{0,62}$/;
140
+ const kumikoDeployConfigSchema = z
141
+ .object({
142
+ dbUser: z.string().regex(DB_USER_RE).optional(),
143
+ stackNetwork: z.enum(["discover", "directory"]).optional(),
144
+ })
145
+ .strict();
146
+ /** `package.json#kumiko.deploy` must be well-formed — silently falling back
147
+ * to defaults on an invalid `dbUser`/`stackNetwork` would render a migrate
148
+ * step that talks to the wrong DB user or the wrong stack network, breaking
149
+ * prod migrations without anyone noticing. Unlike detectOptionalSurfaces'
150
+ * malformed-JSON fallback, this throws (fail loud) and names the field. */
151
+ function parseDeployConfig(raw) {
152
+ if (raw === undefined)
153
+ return {};
154
+ const result = kumikoDeployConfigSchema.safeParse(raw);
155
+ if (result.success)
156
+ return result.data;
157
+ const issue = result.error.issues[0];
158
+ const location = issue === undefined
159
+ ? "kumiko.deploy"
160
+ : issue.code === "unrecognized_keys"
161
+ ? ["kumiko", "deploy", ...issue.keys].join(".")
162
+ : ["kumiko", "deploy", ...issue.path.map(String)].join(".");
163
+ throw new Error(`scaffoldDeploy: invalid package.json#${location} — ${issue?.message ?? "validation failed"}`);
164
+ }
165
+ function resolveDeployConfig(deployConfigRaw, appName) {
166
+ const config = parseDeployConfig(deployConfigRaw);
167
+ const dbUser = config.dbUser ?? appName;
168
+ // The default (appName) already passed isKebabSegment's charset check but
169
+ // not DB_USER_RE's length cap — validate the effective value so an
170
+ // over-long appName fails loud here instead of producing a DB user
171
+ // Postgres itself would reject at migrate-time.
172
+ if (!DB_USER_RE.test(dbUser)) {
173
+ throw new Error(`scaffoldDeploy: invalid package.json#kumiko.deploy.dbUser — effective value "${dbUser}" (defaulted from appName) does not match ${DB_USER_RE}`);
174
+ }
175
+ return { dbUser, stackNetwork: config.stackNetwork ?? "discover" };
176
+ }
177
+ function detectOptionalSurfaces(sourceDir, appName) {
178
+ const hasSeeds = existsSync(join(sourceDir, "seeds"));
179
+ let hasPrivateGhPackages = false;
180
+ let installFromFullTree = false;
181
+ let deployConfigRaw;
182
+ const pkgJsonPath = join(sourceDir, "package.json");
183
+ if (existsSync(pkgJsonPath)) {
184
+ try {
185
+ const raw = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
186
+ // Read ahead of packageJsonSchema.parse: an unrelated shape problem in
187
+ // `dependencies`/`workspaces` must not silently discard a valid deploy
188
+ // config along with it (that's caught by the outer catch below).
189
+ deployConfigRaw = extractDeployConfigRaw(raw);
190
+ const pkg = packageJsonSchema.parse(raw);
191
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
192
+ hasPrivateGhPackages = Object.keys(allDeps).some((d) => d.startsWith("@cosmicdriftgamestudio/"));
193
+ installFromFullTree =
194
+ pkg.workspaces !== undefined ||
195
+ Object.values(allDeps).some((v) => LOCAL_DEP_SPEC_RE.test(v));
196
+ }
197
+ catch (err) {
198
+ // malformed JSON (or unexpected dependencies/workspaces shape) —
199
+ // assume no private packages and a plain manifests-first install;
200
+ // app-author can override via Dockerfile. Warn so a silent
201
+ // mis-detection (later YN0041 on yarn install, or a broken lockfile
202
+ // resolve) is traceable to the scaffold step. If JSON.parse itself
203
+ // failed, deployConfigRaw also stays undefined → deploy params fall
204
+ // back to defaults (an invalid deploy config must THROW, but
205
+ // unreadable JSON can't distinguish "no deploy config" from "invalid
206
+ // deploy config" — it already surfaces via this warning).
207
+ // biome-ignore lint/suspicious/noConsole: scaffold visibility for skipped private-package detection
208
+ console.warn(`scaffoldDeploy: package.json at ${pkgJsonPath} is not valid JSON — private-GH-packages/install-layout detection skipped (${err instanceof Error ? err.message : String(err)})`);
209
+ }
210
+ }
211
+ const registryConfigFiles = REGISTRY_CONFIG_FILES.filter((f) => existsSync(join(sourceDir, f)));
212
+ return {
213
+ hasSeeds,
214
+ hasPrivateGhPackages,
215
+ installFromFullTree,
216
+ registryConfigFiles,
217
+ ...resolveDeployConfig(deployConfigRaw, appName),
218
+ };
219
+ }
220
+ // Unconsumed-mustache guard. After step 1+2 the only legitimate `{{`
221
+ // survivors are Docker/Go template tags (`{{.Name}}`, leading `.`). An
222
+ // orphan close-tag (`{{/foo}}` without an opener) matches neither step and
223
+ // would otherwise leak into the rendered file as a silent failure.
224
+ const ORPHAN_MUSTACHE_RE = /\{\{(?!\.)[^}]*\}\}/;
225
+ export function render(source, subs, flags) {
226
+ // Step 1: handle mustache-style block conditionals `{{#flag}}...{{/flag}}`
227
+ // (multiline-aware via [\s\S]). When flag is truthy → keep inner content;
228
+ // when falsy → strip the entire block (including the surrounding line so
229
+ // we don't leave blank lines in the rendered Dockerfile).
230
+ let result = source.replace(/^[ \t]*\{\{#([a-z][a-zA-Z0-9]*)\}\}\n([\s\S]*?)\n[ \t]*\{\{\/\1\}\}[ \t]*\n?/gm, (_full, key, inner) => {
231
+ const flag = flags[key];
232
+ if (flag === undefined) {
233
+ throw new Error(`scaffoldDeploy.render: unknown block-flag "{{#${key}}}"`);
234
+ }
235
+ return flag ? `${inner}\n` : "";
236
+ });
237
+ // Step 2: handle plain `{{key}}` substitutions. Pattern is intentionally
238
+ // narrow: lowercase-leading identifier followed by alphanumerics. That
239
+ // excludes Docker/Go template syntax like `{{.Name}}` (leading `.`)
240
+ // which appears verbatim in the migrate-step.sh shell snippet.
241
+ result = result.replace(/\{\{([a-z][a-zA-Z0-9]*)\}\}/g, (full, key) => {
242
+ const value = subs[key];
243
+ if (value === undefined) {
244
+ throw new Error(`scaffoldDeploy.render: unknown placeholder "${full}"`);
245
+ }
246
+ return value;
247
+ });
248
+ const orphan = ORPHAN_MUSTACHE_RE.exec(result);
249
+ if (orphan) {
250
+ throw new Error(`scaffoldDeploy.render: unconsumed mustache tag "${orphan[0]}" — ` +
251
+ `likely an orphan close-tag (e.g. a stray "{{/flag}}") in the template.`);
252
+ }
253
+ return result;
254
+ }
@@ -0,0 +1,26 @@
1
+ export type ScaffoldFeatureOptions = {
2
+ /** camelCase feature name. Must be a valid JS identifier. */
3
+ readonly name: string;
4
+ /**
5
+ * Absolute or repo-relative path where the feature workspace gets
6
+ * created. Defaults to `samples/recipes/<kebab-name>/` under the
7
+ * resolved repo root.
8
+ */
9
+ readonly destination?: string;
10
+ /** Repo root used to resolve the default destination. Defaults to cwd. */
11
+ readonly repoRoot?: string;
12
+ };
13
+ export type ScaffoldFeatureResult = {
14
+ readonly destination: string;
15
+ readonly featureFile: string;
16
+ readonly packageJsonFile: string;
17
+ readonly tsconfigFile: string;
18
+ readonly featureName: string;
19
+ readonly packageName: string;
20
+ };
21
+ /**
22
+ * Generate a starter feature workspace at `destination`. Throws when
23
+ * the destination already exists — refuses to overwrite. The caller is
24
+ * expected to run `bun install` afterwards to wire the workspace.
25
+ */
26
+ export declare function scaffoldFeature(options: ScaffoldFeatureOptions): ScaffoldFeatureResult;