@cosmicdrift/kumiko-dev-server 0.322.0 → 0.324.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ // `kumiko-init-deploy` CLI — the shared implementation behind both the
2
+ // standalone `kumiko-init-deploy` bin (published, app-facing) and the
3
+ // monorepo's `bin/commands/init-deploy.ts` (delegates here, no duplicated
4
+ // logic).
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join, relative } from "node:path";
8
+ import {
9
+ getFlag,
10
+ getNumberFlag,
11
+ getStringFlag,
12
+ parseArgs,
13
+ } from "@cosmicdrift/kumiko-framework/arg-parser";
14
+ import * as z from "zod";
15
+ import { checkDeployDrift, type ScaffoldedFile, scaffoldDeploy } from "./scaffold-deploy";
16
+
17
+ export type CliOutput = {
18
+ readonly log: (msg: string) => void;
19
+ readonly err: (msg: string) => void;
20
+ };
21
+
22
+ export type RunInitDeployCliOptions = {
23
+ readonly argv: readonly string[];
24
+ readonly cwd: string;
25
+ readonly out: CliOutput;
26
+ };
27
+
28
+ const USAGE =
29
+ "Usage: kumiko-init-deploy --app <name> [--port <n>] [--github-org <org>] [--out <dir>] [--force | --check]";
30
+
31
+ const packageNameSchema = z.object({ name: z.string().optional() });
32
+
33
+ /** package.json `name` without its `@scope/` prefix, if present and valid. */
34
+ function defaultAppName(cwd: string): string | undefined {
35
+ const pkgPath = join(cwd, "package.json");
36
+ if (!existsSync(pkgPath)) return undefined;
37
+ try {
38
+ const pkg = packageNameSchema.parse(JSON.parse(readFileSync(pkgPath, "utf-8")));
39
+ if (!pkg.name) return undefined;
40
+ const slash = pkg.name.indexOf("/");
41
+ return pkg.name.startsWith("@") && slash !== -1 ? pkg.name.slice(slash + 1) : pkg.name;
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ type DeployTarget = {
48
+ readonly appName: string;
49
+ readonly port: number | undefined;
50
+ readonly githubOrg: string | undefined;
51
+ readonly destination: string;
52
+ };
53
+
54
+ function targetOptions({ appName, port, githubOrg, destination }: DeployTarget) {
55
+ return {
56
+ appName,
57
+ ...(port !== undefined && { port }),
58
+ ...(githubOrg !== undefined && { githubOrg }),
59
+ destination,
60
+ };
61
+ }
62
+
63
+ function reportDrift(target: DeployTarget, out: CliOutput): number {
64
+ const result = checkDeployDrift(targetOptions(target));
65
+ if (result.drifted.length === 0) {
66
+ out.log(" ✓ deploy/ is in sync with the current templates.");
67
+ return 0;
68
+ }
69
+ out.err("");
70
+ out.err(` ✗ ${result.drifted.length} drifted file(s):`);
71
+ for (const d of result.drifted) {
72
+ out.err(` ${relative(target.destination, d.path)} — ${d.reason}`);
73
+ }
74
+ out.err("");
75
+ out.err(" run kumiko-init-deploy --force to regenerate.");
76
+ out.err("");
77
+ return 1;
78
+ }
79
+
80
+ function fileMarker(file: ScaffoldedFile): string {
81
+ if (!file.written) return "SKIPPED ";
82
+ return file.reason === "force" ? "OVERWRITTEN" : "WRITTEN ";
83
+ }
84
+
85
+ function reportScaffold(target: DeployTarget, force: boolean, out: CliOutput): number {
86
+ const result = scaffoldDeploy({ ...targetOptions(target), force });
87
+ out.log("");
88
+ out.log(` ✓ Deploy scaffolding generated — ${target.appName}`);
89
+ for (const f of result.files) {
90
+ out.log(` ${fileMarker(f)} ${relative(target.destination, f.path)}`);
91
+ }
92
+ if (result.files.some((f) => !f.written)) {
93
+ out.log("");
94
+ out.log(
95
+ " Some files were skipped because they already exist. Re-run with --force to overwrite.",
96
+ );
97
+ }
98
+ out.log("");
99
+ out.log(" Next steps:");
100
+ out.log(" 1. Review deploy/Dockerfile — adjust if your app needs extra COPY/ENV steps.");
101
+ out.log(" 2. Wire your image-build workflow to build deploy/Dockerfile and push it.");
102
+ out.log(" 3. Configure the runtime env-vars for your platform.");
103
+ out.log("");
104
+ return 0;
105
+ }
106
+
107
+ export async function runInitDeployCli({
108
+ argv,
109
+ cwd,
110
+ out,
111
+ }: RunInitDeployCliOptions): Promise<number> {
112
+ const args = parseArgs(argv);
113
+ const force = getFlag(args, "force");
114
+ const check = getFlag(args, "check");
115
+ if (force && check) {
116
+ out.err("");
117
+ out.err(" --check and --force are mutually exclusive.");
118
+ out.err(` ${USAGE}`);
119
+ out.err("");
120
+ return 2;
121
+ }
122
+
123
+ const appName = getStringFlag(args, "app") ?? defaultAppName(cwd);
124
+ if (!appName) {
125
+ out.err("");
126
+ out.err(' --app <name> is required (no package.json "name" found to default from).');
127
+ out.err(` ${USAGE}`);
128
+ out.err("");
129
+ return 1;
130
+ }
131
+
132
+ const target: DeployTarget = {
133
+ appName,
134
+ port: getNumberFlag(args, "port"),
135
+ githubOrg: getStringFlag(args, "github-org"),
136
+ destination: getStringFlag(args, "out") ?? cwd,
137
+ };
138
+
139
+ try {
140
+ return check ? reportDrift(target, out) : reportScaffold(target, force, out);
141
+ } catch (err) {
142
+ const msg = err instanceof Error ? err.message : String(err);
143
+ out.err("");
144
+ out.err(` ${msg}`);
145
+ out.err("");
146
+ return 1;
147
+ }
148
+ }
@@ -12,7 +12,11 @@
12
12
  // alternativer Membership-Query, eigener LoginRateLimiter): geht direkt
13
13
  // auf `createKumikoServer` aus @cosmicdrift/kumiko-dev-server.
14
14
 
15
- import { AuthErrors, AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
15
+ import {
16
+ AuthErrors,
17
+ AuthHandlers,
18
+ AuthQueries,
19
+ } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
16
20
  import {
17
21
  type SeedAdminOptions,
18
22
  seedAdminGuarded,
@@ -187,17 +191,16 @@ export type RunDevAppOptions = {
187
191
  readonly features: readonly FeatureDefinition[];
188
192
  /** Opt-in boot-validator warnings — see ValidateBootOptions. */
189
193
  readonly validateBootOptions?: ValidateBootOptions;
190
- /** Pfad zum Browser-Entry-Modul. Bun.build bündelt es zu /client.js.
191
- * Mutually exclusive mit `clientEntries`. */
194
+ /** Browser entry module, bundled to /client.js. Mutually exclusive with
195
+ * `clientEntries`. */
192
196
  readonly clientEntry?: string;
193
- /** Multi-Entry-Mode: pro Entry ein eigenes Bundle (`/client-<name>.js`)
194
- * + ein eigenes HTML-Template. `hostDispatch` wählt zur Request-Zeit
195
- * welcher Entry kommt. Symmetric zur kumiko-build-Convention
196
- * `src/client-<name>.tsx`. Mutually exclusive mit `clientEntry`. */
197
+ /** Multi-entry mode: one bundle (`/client-<name>.js`) and one HTML template
198
+ * per entry; `hostDispatch` picks the entry per request. kumiko-build reads
199
+ * the same list from package.json `kumiko.clientEntries`. Mutually exclusive
200
+ * with `clientEntry`. */
197
201
  readonly clientEntries?: CreateKumikoServerOptions["clientEntries"];
198
- /** Multi-Entry-Mode: Routing per Request. Wird für Multi-Entry mit
199
- * geforderten — sonst weiß der Server nicht welche HTML er liefern
200
- * soll. */
202
+ /** Multi-entry mode: per-request routing. Required with `clientEntries`,
203
+ * otherwise the server cannot tell which HTML to serve. */
201
204
  readonly hostDispatch?: CreateKumikoServerOptions["hostDispatch"];
202
205
  /** Per-request head-metadata resolver — same option, same signature as
203
206
  * `runProdApp`'s `resolvePageHead`, so `bun dev` and every e2e that boots
@@ -578,6 +581,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
578
581
  acceptHandler: AuthHandlers.inviteAccept,
579
582
  acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
580
583
  signupCompleteHandler: AuthHandlers.inviteSignupComplete,
584
+ infoHandler: AuthQueries.inviteInfo,
581
585
  },
582
586
  }),
583
587
  },
@@ -162,7 +162,12 @@ import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/eng
162
162
 
163
163
  export const ${camel}ItemEntity = createEntity({
164
164
  fields: {
165
- title: createTextField({ required: true, sortable: true }),
165
+ title: createTextField({
166
+ required: true,
167
+ sortable: true,
168
+ personal: false,
169
+ reason: "is_business_data",
170
+ }),
166
171
  },
167
172
  });
168
173
  `;
@@ -3,16 +3,21 @@
3
3
  // Generates `deploy/Dockerfile`, `deploy/Dockerfile.dockerignore`, and
4
4
  // `deploy/migrate-step.sh` in the target app from canonical templates
5
5
  // shipped with @cosmicdrift/kumiko-dev-server. Substitutes `{{appName}}`,
6
- // `{{port}}`, `{{githubOrg}}` placeholders. Refuses to overwrite existing
7
- // files unless `force: true` — keeps an app's already-tuned Dockerfile
8
- // from being clobbered.
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.
9
13
 
10
14
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
15
  import { dirname, join } from "node:path";
12
16
  import { fileURLToPath } from "node:url";
17
+ import * as z from "zod";
13
18
  import { isKebabSegment } from "./kebab";
14
19
 
15
- export type ScaffoldDeployOptions = {
20
+ export type RenderDeployFilesOptions = {
16
21
  /** App name, kebab-case (e.g. "publicstatus", "kumiko-studio"). */
17
22
  readonly appName: string;
18
23
  /** Container port the app listens on. Default 3000. */
@@ -26,19 +31,43 @@ export type ScaffoldDeployOptions = {
26
31
  * `destination`. Lets the caller scaffold into one dir while detecting
27
32
  * optional surfaces in another (rare — mostly destination = sourceDir). */
28
33
  readonly sourceDir?: string;
34
+ };
35
+
36
+ export type ScaffoldDeployOptions = RenderDeployFilesOptions & {
29
37
  /** Overwrite existing files instead of skipping them. */
30
38
  readonly force?: boolean;
31
39
  };
32
40
 
33
- /** Detected optional-dirs in the app's source-tree. Drives which COPY
34
- * blocks the Dockerfile-template emits. */
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. */
35
46
  export type ScaffoldDeployDetected = {
36
47
  /** ES-Operations seed-migrations (`seeds/`). Required for apps that
37
48
  * use the es-ops feature. */
38
49
  readonly hasSeeds: boolean;
39
50
  /** Private @cosmicdriftgamestudio/* GH-Packages → Dockerfile needs to
40
- * pass GITHUB_TOKEN as build-arg + re-export inside the build-stage. */
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). */
41
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";
42
71
  };
43
72
 
44
73
  export type ScaffoldedFile = {
@@ -56,6 +85,30 @@ export type ScaffoldDeployResult = {
56
85
  readonly detected: ScaffoldDeployDetected;
57
86
  };
58
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
+
59
112
  const TEMPLATE_FILES = [
60
113
  { template: "Dockerfile.template", output: "Dockerfile" },
61
114
  {
@@ -65,61 +118,81 @@ const TEMPLATE_FILES = [
65
118
  { template: "migrate-step.sh.template", output: "migrate-step.sh" },
66
119
  ] as const;
67
120
 
68
- export function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployResult {
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 {
69
129
  if (!isKebabSegment(options.appName)) {
70
130
  throw new Error(
71
- `scaffoldDeploy: appName must be kebab-case (a-z, 0-9, -); got "${options.appName}"`,
131
+ `renderDeployFiles: appName must be kebab-case (a-z, 0-9, -); got "${options.appName}"`,
72
132
  );
73
133
  }
74
134
  const port = options.port ?? 3000;
75
135
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
76
- throw new Error(`scaffoldDeploy: port must be 1..65535, got ${port}`);
136
+ throw new Error(`renderDeployFiles: port must be 1..65535, got ${port}`);
77
137
  }
78
138
  const githubOrg = options.githubOrg ?? "cosmicdriftgamestudio";
79
139
  const destinationRoot = options.destination ?? process.cwd();
80
140
  const deployDir = join(destinationRoot, "deploy");
81
- mkdirSync(deployDir, { recursive: true });
82
-
83
- const templatesDir = join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "deploy");
84
141
 
85
142
  // Detect optional surfaces in the source-tree so the Dockerfile only
86
143
  // emits COPYs for dirs that actually exist. Without this, apps without
87
144
  // a `seeds/` directory (e.g. studio) crash in Docker-build with
88
145
  // `failed to compute cache key: "/app/seeds": not found`.
89
- //
90
- // `hasPrivateGhPackages`: scan package.json for any
91
- // `@cosmicdriftgamestudio/*` dep — those need GITHUB_TOKEN as a build-
92
- // arg passed through into the build-stage (multi-stage ARG inheritance
93
- // requires re-declaration inside the stage).
94
146
  const sourceDir = options.sourceDir ?? destinationRoot;
95
- const detected = detectOptionalSurfaces(sourceDir);
147
+ const detected = detectOptionalSurfaces(sourceDir, options.appName);
148
+
149
+ const installManifests = ["package.json", "bun.lock", ...detected.registryConfigFiles].join(" ");
96
150
 
97
151
  const subs: Readonly<Record<string, string>> = {
98
152
  appName: options.appName,
99
153
  port: String(port),
100
154
  githubOrg,
155
+ installManifests,
156
+ dbUser: detected.dbUser,
101
157
  };
102
158
 
103
159
  const flags: Readonly<Record<string, boolean>> = {
104
160
  hasSeeds: detected.hasSeeds,
105
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,
106
167
  };
107
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
+
108
183
  const files: ScaffoldedFile[] = [];
109
- for (const { template, output } of TEMPLATE_FILES) {
110
- const outputPath = join(deployDir, output);
111
- const preExisted = existsSync(outputPath);
184
+ for (const { path, content } of rendered) {
185
+ const preExisted = existsSync(path);
112
186
  if (preExisted && !options.force) {
113
- files.push({ path: outputPath, written: false, reason: "exists" });
187
+ files.push({ path, written: false, reason: "exists" });
114
188
  continue;
115
189
  }
116
- const rendered = render(readFileSync(join(templatesDir, template), "utf-8"), subs, flags);
117
- writeFileSync(outputPath, rendered);
190
+ writeFileSync(path, content);
118
191
  // `reason: "force"` only when we actually clobbered a pre-existing
119
192
  // file — distinct from a clean first-time write. The existsSync above
120
193
  // is captured BEFORE the write so the flag reflects pre-state.
121
194
  files.push({
122
- path: outputPath,
195
+ path,
123
196
  written: true,
124
197
  ...(preExisted && options.force ? { reason: "force" as const } : {}),
125
198
  });
@@ -128,31 +201,146 @@ export function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployRe
128
201
  return { destination: deployDir, files, detected };
129
202
  }
130
203
 
131
- function detectOptionalSurfaces(sourceDir: string): ScaffoldDeployDetected {
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 {
132
300
  const hasSeeds = existsSync(join(sourceDir, "seeds"));
133
301
  let hasPrivateGhPackages = false;
302
+ let installFromFullTree = false;
303
+ let deployConfigRaw: unknown;
134
304
  const pkgJsonPath = join(sourceDir, "package.json");
135
305
  if (existsSync(pkgJsonPath)) {
136
306
  try {
137
- const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as {
138
- dependencies?: Record<string, string>;
139
- devDependencies?: Record<string, string>;
140
- };
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);
141
313
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
142
314
  hasPrivateGhPackages = Object.keys(allDeps).some((d) =>
143
315
  d.startsWith("@cosmicdriftgamestudio/"),
144
316
  );
317
+ installFromFullTree =
318
+ pkg.workspaces !== undefined ||
319
+ Object.values(allDeps).some((v) => LOCAL_DEP_SPEC_RE.test(v));
145
320
  } catch (err) {
146
- // malformed package.json — assume no private packages, app-author can
147
- // override via Dockerfile. Warn so a silent mis-detection (later YN0041
148
- // on yarn install) is traceable to the scaffold step.
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).
149
330
  // biome-ignore lint/suspicious/noConsole: scaffold visibility for skipped private-package detection
150
331
  console.warn(
151
- `scaffoldDeploy: package.json at ${pkgJsonPath} is not valid JSON — private-GH-packages detection skipped (${err instanceof Error ? err.message : String(err)})`,
332
+ `scaffoldDeploy: package.json at ${pkgJsonPath} is not valid JSON — private-GH-packages/install-layout detection skipped (${err instanceof Error ? err.message : String(err)})`,
152
333
  );
153
334
  }
154
335
  }
155
- return { hasSeeds, hasPrivateGhPackages };
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
+ };
156
344
  }
157
345
 
158
346
  // Unconsumed-mustache guard. After step 1+2 the only legitimate `{{`
@@ -1,4 +1,4 @@
1
- # syntax=docker/dockerfile:1.7
1
+ # syntax=docker/dockerfile:1.27
2
2
  #
3
3
  # Production-Image for {{appName}}.
4
4
  #
@@ -13,10 +13,11 @@
13
13
 
14
14
  ARG BUN_VERSION=1.4.0
15
15
  {{#hasPrivateGhPackages}}
16
- # Private GH-Packages: der CI-Workflow injects GITHUB_TOKEN via --build-arg.
17
- # Docker's multi-stage ARG scoping braucht die Re-declaration in jeder Stage
18
- # die sie nutzt; das top-level ARG macht sie per `docker build` setzbar.
19
- ARG GITHUB_TOKEN=
16
+ # Registry auth for @cosmicdriftgamestudio: CI passes the token as
17
+ # NPM_AUTH_TOKEN via --build-arg. Multi-stage ARG scoping needs the
18
+ # re-declaration inside the stage that uses it; this top-level ARG only
19
+ # makes it settable via `docker build`.
20
+ ARG NPM_AUTH_TOKEN=
20
21
  {{/hasPrivateGhPackages}}
21
22
  # Build identity — passed in by the CI workflow via --build-arg. Defaults
22
23
  # for local container builds.
@@ -27,24 +28,34 @@ ARG BUILD_TIME=unknown
27
28
  FROM oven/bun:${BUN_VERSION}-alpine AS build
28
29
  WORKDIR /app
29
30
  {{#hasPrivateGhPackages}}
30
- # GITHUB_TOKEN für @cosmicdriftgamestudio/*-Scope beim bun install.
31
- ARG GITHUB_TOKEN
32
- ENV GITHUB_TOKEN=${GITHUB_TOKEN}
33
- RUN bun config set --scope @cosmicdriftgamestudio token "${GITHUB_TOKEN}"
31
+ # bunfig.toml/.npmrc resolve the @cosmicdriftgamestudio scope via $GITHUB_TOKEN.
32
+ ARG NPM_AUTH_TOKEN
33
+ ENV GITHUB_TOKEN=${NPM_AUTH_TOKEN}
34
34
  {{/hasPrivateGhPackages}}
35
35
 
36
- # Standalone-repo layout: @cosmicdrift/* pulled from NPM, no workspace:*
37
- # refs. Manifests first for Docker layer cache (bun install only
38
- # invalidates on dep change).
39
- COPY package.json bun.lock ./
40
-
36
+ {{#installFromFullTree}}
37
+ # file:/workspace:/link: deps (or a workspaces root) need the whole tree on
38
+ # disk for bun to resolve the lockfile — a partial COPY makes bun treat it
39
+ # as empty and rewrite it.
40
+ COPY . .
41
+ RUN bun install --frozen-lockfile
42
+ RUN bun run build
43
+ {{/installFromFullTree}}
44
+ {{#installFromManifests}}
45
+ # Manifests first for Docker layer cache — bun install only invalidates on
46
+ # dependency change, not on every source edit.
47
+ COPY {{installManifests}} ./
41
48
  RUN bun install --frozen-lockfile
42
-
43
49
  COPY . .
44
50
  RUN bun run build
51
+ {{/installFromManifests}}
45
52
 
46
53
  # ----- runtime: bun-alpine, knows only the bundle artefact ------------------
47
54
  FROM oven/bun:${BUN_VERSION}-alpine AS runtime
55
+ # Global ARGs are invisible inside a stage until re-declared — without this,
56
+ # ENV BUILD_VERSION/BUILD_TIME below stay empty despite a correct --build-arg.
57
+ ARG BUILD_VERSION=dev
58
+ ARG BUILD_TIME=unknown
48
59
  WORKDIR /app
49
60
 
50
61
  RUN addgroup -S app && adduser -S app -G app
@@ -63,11 +74,10 @@ COPY --from=build --chown=app:app /app/dist ./dist
63
74
  # (only generate reads it; apply is checksum-tracked SQL only).
64
75
  COPY --from=build --chown=app:app /app/kumiko/migrations ./kumiko/migrations
65
76
 
66
- # Container entrypoint — `infra/pulumi/bun-server.ts` overrides the container
67
- # command to inject DATABASE_URL from the init-container's /shared/database-url
68
- # then execs `./start.sh`. We generate it inline so app-source-roots stay
69
- # clean (no per-app start.sh duplication). Apps that don't go through
70
- # createBunServer's command-override still boot via the CMD at the bottom
77
+ # Container entrypoint — the platform may override the container command to
78
+ # inject runtime secrets, then execs `./start.sh`. We generate it inline so
79
+ # app-source-roots stay clean (no per-app start.sh duplication). Apps that
80
+ # don't go through a command-override still boot via the CMD at the bottom
71
81
  # (`exec bun run server.js`) — start.sh is dead-code in that case.
72
82
  RUN printf '#!/bin/sh\nset -e\nexec bun run server.js\n' > ./start.sh && chmod +x ./start.sh
73
83
 
@@ -85,6 +95,8 @@ COPY --from=build --chown=app:app /app/seeds ./seeds
85
95
  USER app
86
96
 
87
97
  ENV NODE_ENV=production
98
+ # The framework assumes UTC.
99
+ ENV TZ=UTC
88
100
  ENV PORT={{port}}
89
101
  # The bundled kumiko.js CLI resolves its repo-relative paths via
90
102
  # KUMIKO_REPO_ROOT (default: parent-of-import.meta.dir, which inside the