@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.
- package/README.md +17 -0
- package/bin/kumiko-build.ts +3 -2
- package/bin/kumiko-dev.ts +1 -1
- package/bin/kumiko-init-deploy.ts +1 -1
- package/bin/kumiko-schema-check.ts +4 -1
- package/dist/build-server-bundle.d.ts +31 -0
- package/dist/build-server-bundle.js +282 -0
- package/dist/build.d.ts +2 -0
- package/dist/build.js +5 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +9 -0
- package/dist/codegen/index.d.ts +4 -0
- package/dist/codegen/index.js +4 -0
- package/dist/codegen/render.d.ts +55 -0
- package/dist/codegen/render.js +273 -0
- package/dist/codegen/run-codegen.d.ts +20 -0
- package/dist/codegen/run-codegen.js +219 -0
- package/dist/codegen/scan-events.d.ts +76 -0
- package/dist/codegen/scan-events.js +447 -0
- package/dist/codegen/watch.d.ts +33 -0
- package/dist/codegen/watch.js +112 -0
- package/dist/compose-stacks.d.ts +60 -0
- package/dist/compose-stacks.js +127 -0
- package/dist/crash-tracker.d.ts +17 -0
- package/dist/crash-tracker.js +37 -0
- package/dist/create-kumiko-server.d.ts +195 -0
- package/dist/create-kumiko-server.js +1040 -0
- package/dist/env-schema.d.ts +15 -0
- package/dist/env-schema.js +60 -0
- package/dist/few-shot-corpus.d.ts +72 -0
- package/dist/few-shot-corpus.js +262 -0
- package/dist/framework-version.d.ts +1 -0
- package/dist/framework-version.js +31 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +33 -0
- package/dist/init-deploy-cli.d.ts +10 -0
- package/dist/init-deploy-cli.js +111 -0
- package/dist/kebab.d.ts +1 -0
- package/{src/kebab.ts → dist/kebab.js} +2 -3
- package/dist/run-dev-app.d.ts +174 -0
- package/dist/run-dev-app.js +403 -0
- package/dist/scaffold-app-feature.d.ts +16 -0
- package/dist/scaffold-app-feature.js +240 -0
- package/dist/scaffold-app.d.ts +45 -0
- package/dist/scaffold-app.js +852 -0
- package/dist/scaffold-demo-tasks.d.ts +8 -0
- package/{src/scaffold-demo-tasks.ts → dist/scaffold-demo-tasks.js} +69 -98
- package/dist/scaffold-deploy.d.ts +92 -0
- package/dist/scaffold-deploy.js +254 -0
- package/dist/scaffold-feature.d.ts +26 -0
- package/dist/scaffold-feature.js +163 -0
- package/dist/schema-apply.d.ts +11 -0
- package/dist/schema-apply.js +95 -0
- package/dist/schema-check-core.d.ts +2 -0
- package/{src/schema-check-core.ts → dist/schema-check-core.js} +8 -10
- package/dist/schema-tables-auth-mode.d.ts +2 -0
- package/{src/schema-tables-auth-mode.ts → dist/schema-tables-auth-mode.js} +2 -10
- package/dist/schema-tables-minimal.d.ts +2 -0
- package/{src/schema-tables-minimal.ts → dist/schema-tables-minimal.js} +2 -11
- package/dist/setup-test-stack-from-features.d.ts +11 -0
- package/dist/setup-test-stack-from-features.js +42 -0
- package/dist/welcome-banner.d.ts +15 -0
- package/dist/welcome-banner.js +27 -0
- package/package.json +71 -24
- package/src/changes.json +6 -0
- package/src/__tests__/build-prod-bundle.integration.test.ts +0 -282
- package/src/__tests__/build-server-bundle.test.ts +0 -228
- package/src/__tests__/classify-change.test.ts +0 -87
- package/src/__tests__/compose-stacks.test.ts +0 -273
- package/src/__tests__/config-resolver-default.integration.test.ts +0 -165
- package/src/__tests__/crash-tracker.test.ts +0 -89
- package/src/__tests__/create-kumiko-server-errors.test.ts +0 -54
- package/src/__tests__/create-kumiko-server-options.test.ts +0 -33
- package/src/__tests__/create-kumiko-server.integration.test.ts +0 -1007
- package/src/__tests__/discover-format.test.ts +0 -32
- package/src/__tests__/env-schema.integration.test.ts +0 -248
- package/src/__tests__/env-schema.test.ts +0 -132
- package/src/__tests__/few-shot-corpus.test.ts +0 -316
- package/src/__tests__/framework-version.test.ts +0 -45
- package/src/__tests__/init-deploy-cli.test.ts +0 -113
- package/src/__tests__/kumiko-schema-check.test.ts +0 -56
- package/src/__tests__/merge-extra-context.test.ts +0 -67
- package/src/__tests__/resolve-stylesheet.test.ts +0 -132
- package/src/__tests__/run-dev-app-boot-validation.test.ts +0 -38
- package/src/__tests__/run-dev-app.integration.test.ts +0 -290
- package/src/__tests__/runtime-classification.test.ts +0 -26
- package/src/__tests__/saas-identity-wire.integration.test.ts +0 -427
- package/src/__tests__/scaffold-app-feature.test.ts +0 -216
- package/src/__tests__/scaffold-app.test.ts +0 -630
- package/src/__tests__/scaffold-demo-tasks.test.ts +0 -47
- package/src/__tests__/scaffold-deploy.test.ts +0 -464
- package/src/__tests__/scaffold-dev-cycle.integration.test.ts +0 -109
- package/src/__tests__/scaffold-feature.test.ts +0 -143
- package/src/__tests__/schema-apply.integration.test.ts +0 -191
- package/src/__tests__/setup-test-stack-from-features.integration.test.ts +0 -30
- package/src/__tests__/setup-test-stack-job-run-logger.integration.test.ts +0 -72
- package/src/__tests__/walkthrough.integration.test.ts +0 -140
- package/src/__tests__/welcome-banner.test.ts +0 -42
- package/src/build-server-bundle.ts +0 -346
- package/src/build.ts +0 -28
- package/src/codegen/__tests__/render-codegen.test.ts +0 -100
- package/src/codegen/__tests__/run-codegen.test.ts +0 -665
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +0 -347
- package/src/codegen/__tests__/watch.test.ts +0 -222
- package/src/codegen/index.ts +0 -19
- package/src/codegen/render.ts +0 -305
- package/src/codegen/run-codegen.ts +0 -278
- package/src/codegen/scan-events.ts +0 -565
- package/src/codegen/watch.ts +0 -140
- package/src/compose-stacks.ts +0 -193
- package/src/crash-tracker.ts +0 -56
- package/src/create-kumiko-server.ts +0 -1397
- package/src/env-schema.ts +0 -72
- package/src/few-shot-corpus.ts +0 -370
- package/src/framework-version.ts +0 -39
- package/src/index.ts +0 -105
- package/src/init-deploy-cli.ts +0 -148
- package/src/run-dev-app.ts +0 -694
- package/src/scaffold-app-feature.ts +0 -283
- package/src/scaffold-app.ts +0 -1033
- package/src/scaffold-deploy.ts +0 -393
- package/src/scaffold-feature.ts +0 -226
- package/src/schema-apply.ts +0 -123
- package/src/setup-test-stack-from-features.ts +0 -80
- package/src/welcome-banner.ts +0 -43
package/src/scaffold-deploy.ts
DELETED
|
@@ -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
|
-
}
|
package/src/scaffold-feature.ts
DELETED
|
@@ -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
|
-
}
|