@cosmicdrift/kumiko-dev-server 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # @cosmicdrift/kumiko-dev-server
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 51e22f5: Add deploy-template scaffolding (Sprint 9.6).
8
+
9
+ **New API:**
10
+
11
+ - `scaffoldDeploy({ appName, port?, githubOrg?, destination?, force? })` exported from `@cosmicdrift/kumiko-dev-server`. Generates `deploy/Dockerfile`, `deploy/Dockerfile.dockerignore`, and `deploy/migrate-step.sh` from canonical templates shipped with the package. Substitutes `{{appName}}`, `{{port}}`, `{{githubOrg}}` placeholders.
12
+ - New CLI command: `kumiko init-deploy --app <name> [--port <n>] [--github-org <org>] [--out <dir>] [--force]`.
13
+
14
+ The templates are extracted from publicstatus's production-tested `deploy/Dockerfile` (node-alpine build stage → bun-alpine runtime, drizzle migrations baked in, healthcheck wired). Refuses to overwrite existing files unless `--force` is passed so a tuned per-app Dockerfile isn't clobbered.
15
+
16
+ **Templates are a starting point, not a contract.** Apps should review and adjust:
17
+
18
+ - **Image tag** is hardcoded `:latest` in `migrate-step.sh.template`. Swap to `:${BUILD_SHA}` for atomic deploys.
19
+ - **DB defaults** in `migrate-step.sh.template` assume `db user = db name = appName`, host `db`, port `5432`. Adjust to your stack.
20
+ - **`COPY /app/seeds`** assumes the app uses ES-Operations seed migrations. Comment out if your app has no `seeds/` directory (otherwise `docker build` fails).
21
+ - **`docker build`-smoke-test:** the templates run untested against a non-publicstatus app-tree. Verify locally before pushing to CI.
22
+
23
+ **Deferred to Sprint 9.7+:** `.github/workflows/build-image.yml.suggested`, `pulumi/secrets-bootstrap.sh`, `pulumi/extraEnv.snippet.ts`.
24
+
25
+ **Plan-Doc drift (for 9.9 update):** Plan-Doc-Tabelle nennt `start.sh` (in-container migrate-then-run); diese Implementation liefert `migrate-step.sh` (host-side deploy-pipeline). Beide Konzepte sind gültig — Plan-Doc-Update sollte das klarstellen.
26
+
27
+ - 37fe758: `scaffoldDeploy()` inspects the app source-tree and emits Dockerfile blocks conditionally (Sprint 9.6 follow-up).
28
+
29
+ **Why this exists:** Sprint 9.6's first Dockerfile.template hardcoded `COPY --from=build /app/seeds ./seeds` with a "comment out if you don't use it" note in the changeset. Apps without a `seeds/` directory (e.g. studio.kumiko.so) crashed in Docker-build with `failed to compute cache key: "/app/seeds": not found`. Root-cause was a framework issue (template too rigid), not a per-app symptom — the framework should detect what the app actually has.
30
+
31
+ **Detection:**
32
+
33
+ - `hasSeeds` — `exists(sourceDir/seeds)`. Drives the ES-Ops `COPY ./seeds` block in the runtime stage.
34
+ - `hasPrivateGhPackages` — scan `package.json` `dependencies` + `devDependencies` for any `@cosmicdriftgamestudio/*` entry. Drives the `ARG GITHUB_TOKEN` blocks (multi-stage with explicit re-declaration inside the build-stage) and the `ENV GITHUB_TOKEN=${GITHUB_TOKEN}` re-export before `yarn install --immutable`.
35
+
36
+ **Template syntax:** mustache-style block conditionals `{{#flag}}…{{/flag}}` (multi-line via `[\s\S]`, surrounding line stripped on falsy). Plain `{{key}}` placeholder substitution is unchanged.
37
+
38
+ **New API:**
39
+
40
+ - `ScaffoldDeployOptions.sourceDir?: string` — defaults to `destination`. Lets the caller scaffold into one dir while detecting optional surfaces in another (rare).
41
+ - `ScaffoldDeployResult.detected: { hasSeeds, hasPrivateGhPackages }` — surfaced so the CLI can report what was emitted.
42
+
43
+ **6 new tests:** seeds detection (with + without), GH-Packages detection (private + public-only + malformed package.json).
44
+
45
+ Sprint 9.6's "starting point not contract" disclaimer in the original changeset is now obsolete for these two surfaces — apps no longer need to manually comment out lines.
46
+
47
+ ### Patch Changes
48
+
49
+ - Updated dependencies [51e22f5]
50
+ - @cosmicdrift/kumiko-framework@0.9.0
51
+ - @cosmicdrift/kumiko-bundled-features@0.9.0
52
+
53
+ ## 0.8.1
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies [4b5f91e]
58
+ - @cosmicdrift/kumiko-framework@0.8.1
59
+ - @cosmicdrift/kumiko-bundled-features@0.8.1
60
+
3
61
  ## 0.8.0
4
62
 
5
63
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -48,8 +48,8 @@
48
48
  "kumiko-dev": "./bin/kumiko-dev.ts"
49
49
  },
50
50
  "dependencies": {
51
- "@cosmicdrift/kumiko-bundled-features": "0.8.0",
52
- "@cosmicdrift/kumiko-framework": "0.8.0"
51
+ "@cosmicdrift/kumiko-bundled-features": "0.9.0",
52
+ "@cosmicdrift/kumiko-framework": "0.9.0"
53
53
  },
54
54
  "publishConfig": {
55
55
  "registry": "https://registry.npmjs.org",
@@ -57,6 +57,7 @@
57
57
  },
58
58
  "files": [
59
59
  "src",
60
+ "templates",
60
61
  "README.md",
61
62
  "LICENSE"
62
63
  ]
@@ -0,0 +1,170 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { scaffoldDeploy } from "../scaffold-deploy";
6
+
7
+ describe("scaffoldDeploy", () => {
8
+ let tmp: string;
9
+
10
+ beforeEach(() => {
11
+ tmp = mkdtempSync(join(tmpdir(), "kumiko-deploy-"));
12
+ });
13
+ afterEach(() => {
14
+ rmSync(tmp, { recursive: true, force: true });
15
+ });
16
+
17
+ it("generates Dockerfile, Dockerfile.dockerignore, migrate-step.sh", () => {
18
+ const result = scaffoldDeploy({ appName: "myapp", destination: tmp });
19
+ expect(result.destination).toBe(join(tmp, "deploy"));
20
+ expect(result.files).toHaveLength(3);
21
+ expect(result.files.every((f) => f.written)).toBe(true);
22
+ expect(existsSync(join(tmp, "deploy", "Dockerfile"))).toBe(true);
23
+ expect(existsSync(join(tmp, "deploy", "Dockerfile.dockerignore"))).toBe(true);
24
+ expect(existsSync(join(tmp, "deploy", "migrate-step.sh"))).toBe(true);
25
+ });
26
+
27
+ it("substitutes {{appName}} + {{port}} + {{githubOrg}}", () => {
28
+ scaffoldDeploy({
29
+ appName: "myapp",
30
+ port: 4242,
31
+ githubOrg: "acme",
32
+ destination: tmp,
33
+ });
34
+ const dockerfile = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
35
+ expect(dockerfile).toContain("Production-Image for myapp");
36
+ expect(dockerfile).toContain("ENV PORT=4242");
37
+ expect(dockerfile).toContain("EXPOSE 4242");
38
+
39
+ const migrate = readFileSync(join(tmp, "deploy", "migrate-step.sh"), "utf-8");
40
+ expect(migrate).toContain("myapp pre-deploy migrate step");
41
+ expect(migrate).toContain("ghcr.io/acme/myapp:latest");
42
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: shell-substitution literal, not a JS template
43
+ expect(migrate).toContain("postgresql://myapp:${DB_PASSWORD}@db:5432/myapp");
44
+ // Docker template syntax {{.Name}} must pass through verbatim — our
45
+ // placeholder regex only matches lowercase-leading identifiers.
46
+ expect(migrate).toContain('"{{.Name}}"');
47
+ });
48
+
49
+ it("uses defaults when port + githubOrg are omitted", () => {
50
+ scaffoldDeploy({ appName: "minimal", destination: tmp });
51
+ const dockerfile = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
52
+ expect(dockerfile).toContain("ENV PORT=3000");
53
+ expect(dockerfile).toContain("EXPOSE 3000");
54
+
55
+ const migrate = readFileSync(join(tmp, "deploy", "migrate-step.sh"), "utf-8");
56
+ expect(migrate).toContain("ghcr.io/cosmicdriftgamestudio/minimal:latest");
57
+ });
58
+
59
+ it("skips existing files by default", () => {
60
+ const existing = join(tmp, "deploy");
61
+ scaffoldDeploy({ appName: "first", destination: tmp });
62
+ writeFileSync(join(existing, "Dockerfile"), "# user-tuned, do not touch");
63
+ const second = scaffoldDeploy({ appName: "first", destination: tmp });
64
+ const dockerfile = second.files.find((f) => f.path.endsWith("/Dockerfile"));
65
+ expect(dockerfile?.written).toBe(false);
66
+ expect(dockerfile?.reason).toBe("exists");
67
+ expect(readFileSync(join(existing, "Dockerfile"), "utf-8")).toBe("# user-tuned, do not touch");
68
+ });
69
+
70
+ it("overwrites existing files with --force and tags reason='force'", () => {
71
+ scaffoldDeploy({ appName: "first", destination: tmp });
72
+ writeFileSync(join(tmp, "deploy", "Dockerfile"), "# old content");
73
+ const second = scaffoldDeploy({ appName: "first", destination: tmp, force: true });
74
+ const dockerfile = second.files.find((f) => f.path.endsWith("/Dockerfile"));
75
+ expect(dockerfile?.written).toBe(true);
76
+ expect(dockerfile?.reason).toBe("force");
77
+ expect(readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8")).toContain(
78
+ "Production-Image for first",
79
+ );
80
+ });
81
+
82
+ it("force=true on a fresh directory leaves reason undefined (no clobber happened)", () => {
83
+ // Regression guard: a clean first-time write with force=true must NOT
84
+ // be tagged as `reason: "force"` — that label is reserved for actual
85
+ // overwrites of pre-existing files. Fixed in self-review after the
86
+ // initial implementation set `reason: "force"` unconditionally
87
+ // post-write.
88
+ const result = scaffoldDeploy({ appName: "fresh", destination: tmp, force: true });
89
+ for (const f of result.files) {
90
+ expect(f.written).toBe(true);
91
+ expect(f.reason).toBeUndefined();
92
+ }
93
+ });
94
+
95
+ it("rejects appName that isn't kebab-case", () => {
96
+ expect(() => scaffoldDeploy({ appName: "MyApp", destination: tmp })).toThrow(/kebab-case/);
97
+ expect(() => scaffoldDeploy({ appName: "my_app", destination: tmp })).toThrow(/kebab-case/);
98
+ expect(() => scaffoldDeploy({ appName: "1app", destination: tmp })).toThrow(/kebab-case/);
99
+ });
100
+
101
+ it("rejects out-of-range port", () => {
102
+ expect(() => scaffoldDeploy({ appName: "x", port: 0, destination: tmp })).toThrow(/1\.\.65535/);
103
+ expect(() => scaffoldDeploy({ appName: "x", port: 70000, destination: tmp })).toThrow(
104
+ /1\.\.65535/,
105
+ );
106
+ });
107
+
108
+ describe("source-tree detection", () => {
109
+ it("emits seeds-COPY block only when seeds/ exists", () => {
110
+ // Without seeds/: block stripped
111
+ const without = scaffoldDeploy({ appName: "noseeds", destination: tmp });
112
+ expect(without.detected.hasSeeds).toBe(false);
113
+ const dfNo = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
114
+ expect(dfNo).not.toContain("COPY --from=build --chown=app:app /app/seeds ./seeds");
115
+ expect(dfNo).not.toContain("ES-Operations seed migrations");
116
+ });
117
+
118
+ it("emits seeds-COPY block when seeds/ exists", () => {
119
+ mkdirSync(join(tmp, "seeds"), { recursive: true });
120
+ writeFileSync(join(tmp, "seeds", ".keep"), "");
121
+ const result = scaffoldDeploy({ appName: "withseeds", destination: tmp });
122
+ expect(result.detected.hasSeeds).toBe(true);
123
+ const df = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
124
+ expect(df).toContain("COPY --from=build --chown=app:app /app/seeds ./seeds");
125
+ expect(df).toContain("ES-Operations seed migrations");
126
+ });
127
+
128
+ it("emits GITHUB_TOKEN blocks when @cosmicdriftgamestudio/* dep is present", () => {
129
+ writeFileSync(
130
+ join(tmp, "package.json"),
131
+ JSON.stringify({
132
+ name: "ghapp",
133
+ dependencies: {
134
+ "@cosmicdriftgamestudio/kumiko-ai-foundation": "^0.2.0",
135
+ },
136
+ }),
137
+ );
138
+ const result = scaffoldDeploy({ appName: "ghapp", destination: tmp });
139
+ expect(result.detected.hasPrivateGhPackages).toBe(true);
140
+ const df = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
141
+ expect(df).toContain("ARG GITHUB_TOKEN=");
142
+ expect(df).toContain("ARG GITHUB_TOKEN\n");
143
+ expect(df).toContain("ENV GITHUB_TOKEN=${GITHUB_TOKEN}");
144
+ });
145
+
146
+ it("skips GITHUB_TOKEN blocks when only public @cosmicdrift/* deps are present", () => {
147
+ writeFileSync(
148
+ join(tmp, "package.json"),
149
+ JSON.stringify({
150
+ name: "publicapp",
151
+ dependencies: {
152
+ "@cosmicdrift/kumiko-framework": "^0.8.0",
153
+ "@cosmicdrift/kumiko-bundled-features": "^0.8.0",
154
+ },
155
+ }),
156
+ );
157
+ const result = scaffoldDeploy({ appName: "publicapp", destination: tmp });
158
+ expect(result.detected.hasPrivateGhPackages).toBe(false);
159
+ const df = readFileSync(join(tmp, "deploy", "Dockerfile"), "utf-8");
160
+ expect(df).not.toContain("ARG GITHUB_TOKEN");
161
+ expect(df).not.toContain("ENV GITHUB_TOKEN");
162
+ });
163
+
164
+ it("malformed package.json doesn't crash detection (defaults to no private deps)", () => {
165
+ writeFileSync(join(tmp, "package.json"), "{ this is not json");
166
+ const result = scaffoldDeploy({ appName: "broken", destination: tmp });
167
+ expect(result.detected.hasPrivateGhPackages).toBe(false);
168
+ });
169
+ });
170
+ });
@@ -52,7 +52,10 @@ async function waitFor(
52
52
  predicate: () => boolean,
53
53
  opts: { timeout?: number; interval?: number; label?: string } = {},
54
54
  ): Promise<void> {
55
- const timeout = opts.timeout ?? 2000;
55
+ // Default 5000ms fchokidar-FS-watch events take >2s under CI load on
56
+ // the cdgs-runner (Memory feedback_watch_test_flaky, observed 3× in
57
+ // a row on PR #80). 5s gives headroom without slowing the happy-path.
58
+ const timeout = opts.timeout ?? 5000;
56
59
  const interval = opts.interval ?? 25;
57
60
  const deadline = Date.now() + timeout;
58
61
  while (!predicate()) {
@@ -122,7 +125,7 @@ export default defineFeature("orders", (r) => {
122
125
  // polling adapts to the actual schedule instead of guessing a fixed
123
126
  // sleep.
124
127
  await waitFor(() => results.length >= 2, {
125
- timeout: 2000,
128
+ timeout: 5000,
126
129
  label: "second codegen result",
127
130
  });
128
131
 
@@ -174,7 +177,7 @@ export default defineFeature("orders", (r) => {
174
177
  // soon as the new result lands, confirming the watcher is alive.
175
178
  writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("ignore-css", "after"));
176
179
  await waitFor(() => results.length > afterNonTs, {
177
- timeout: 2000,
180
+ timeout: 5000,
178
181
  label: "ts-change result after non-ts noise",
179
182
  });
180
183
 
package/src/index.ts CHANGED
@@ -54,5 +54,11 @@ export type {
54
54
  SignupSetup,
55
55
  } from "./run-prod-app";
56
56
  export { runProdApp } from "./run-prod-app";
57
+ export type {
58
+ ScaffoldDeployOptions,
59
+ ScaffoldDeployResult,
60
+ ScaffoldedFile,
61
+ } from "./scaffold-deploy";
62
+ export { scaffoldDeploy } from "./scaffold-deploy";
57
63
  export type { ScaffoldFeatureOptions, ScaffoldFeatureResult } from "./scaffold-feature";
58
64
  export { scaffoldFeature } from "./scaffold-feature";
@@ -0,0 +1,186 @@
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}}` placeholders. Refuses to overwrite existing
7
+ // files unless `force: true` — keeps an app's already-tuned Dockerfile
8
+ // from being clobbered.
9
+
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ export type ScaffoldDeployOptions = {
15
+ /** App name, kebab-case (e.g. "publicstatus", "kumiko-studio"). */
16
+ readonly appName: string;
17
+ /** Container port the app listens on. Default 3000. */
18
+ readonly port?: number;
19
+ /** GitHub org for the published image-tag. Default "cosmicdriftgamestudio". */
20
+ readonly githubOrg?: string;
21
+ /** Destination directory (absolute or relative to cwd). The `deploy/`
22
+ * subdir is created inside this. Default: process.cwd(). */
23
+ readonly destination?: string;
24
+ /** Source-tree root for optional-dir detection (seeds/, …). Defaults to
25
+ * `destination`. Lets the caller scaffold into one dir while detecting
26
+ * optional surfaces in another (rare — mostly destination = sourceDir). */
27
+ readonly sourceDir?: string;
28
+ /** Overwrite existing files instead of skipping them. */
29
+ readonly force?: boolean;
30
+ };
31
+
32
+ /** Detected optional-dirs in the app's source-tree. Drives which COPY
33
+ * blocks the Dockerfile-template emits. */
34
+ export type ScaffoldDeployDetected = {
35
+ /** ES-Operations seed-migrations (`seeds/`). Required for apps that
36
+ * use the es-ops feature. */
37
+ readonly hasSeeds: boolean;
38
+ /** Private @cosmicdriftgamestudio/* GH-Packages → Dockerfile needs to
39
+ * pass GITHUB_TOKEN as build-arg + re-export inside the build-stage. */
40
+ readonly hasPrivateGhPackages: boolean;
41
+ };
42
+
43
+ export type ScaffoldedFile = {
44
+ readonly path: string;
45
+ readonly written: boolean;
46
+ readonly reason?: "exists" | "force";
47
+ };
48
+
49
+ export type ScaffoldDeployResult = {
50
+ readonly destination: string;
51
+ readonly files: readonly ScaffoldedFile[];
52
+ /** What scaffoldDeploy detected in the source-tree and used to gate
53
+ * conditional Dockerfile blocks. Surfaced so the CLI can report it
54
+ * ("hasSeeds=true → /app/seeds COPY emitted"). */
55
+ readonly detected: ScaffoldDeployDetected;
56
+ };
57
+
58
+ const TEMPLATE_FILES = [
59
+ { template: "Dockerfile.template", output: "Dockerfile" },
60
+ {
61
+ template: "Dockerfile.dockerignore.template",
62
+ output: "Dockerfile.dockerignore",
63
+ },
64
+ { template: "migrate-step.sh.template", output: "migrate-step.sh" },
65
+ ] as const;
66
+
67
+ const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
68
+
69
+ export function scaffoldDeploy(options: ScaffoldDeployOptions): ScaffoldDeployResult {
70
+ if (!KEBAB_RE.test(options.appName)) {
71
+ throw new Error(
72
+ `scaffoldDeploy: appName must be kebab-case (a-z, 0-9, -); got "${options.appName}"`,
73
+ );
74
+ }
75
+ const port = options.port ?? 3000;
76
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
77
+ throw new Error(`scaffoldDeploy: port must be 1..65535, got ${port}`);
78
+ }
79
+ const githubOrg = options.githubOrg ?? "cosmicdriftgamestudio";
80
+ const destinationRoot = options.destination ?? process.cwd();
81
+ const deployDir = join(destinationRoot, "deploy");
82
+ mkdirSync(deployDir, { recursive: true });
83
+
84
+ const templatesDir = join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "deploy");
85
+
86
+ // Detect optional surfaces in the source-tree so the Dockerfile only
87
+ // emits COPYs for dirs that actually exist. Without this, apps without
88
+ // a `seeds/` directory (e.g. studio) crash in Docker-build with
89
+ // `failed to compute cache key: "/app/seeds": not found`.
90
+ //
91
+ // `hasPrivateGhPackages`: scan package.json for any
92
+ // `@cosmicdriftgamestudio/*` dep — those need GITHUB_TOKEN as a build-
93
+ // arg passed through into the build-stage (multi-stage ARG inheritance
94
+ // requires re-declaration inside the stage).
95
+ const sourceDir = options.sourceDir ?? destinationRoot;
96
+ const detected = detectOptionalSurfaces(sourceDir);
97
+
98
+ const subs: Readonly<Record<string, string>> = {
99
+ appName: options.appName,
100
+ port: String(port),
101
+ githubOrg,
102
+ };
103
+
104
+ const flags: Readonly<Record<string, boolean>> = {
105
+ hasSeeds: detected.hasSeeds,
106
+ hasPrivateGhPackages: detected.hasPrivateGhPackages,
107
+ };
108
+
109
+ const files: ScaffoldedFile[] = [];
110
+ for (const { template, output } of TEMPLATE_FILES) {
111
+ const outputPath = join(deployDir, output);
112
+ const preExisted = existsSync(outputPath);
113
+ if (preExisted && !options.force) {
114
+ files.push({ path: outputPath, written: false, reason: "exists" });
115
+ continue;
116
+ }
117
+ const rendered = render(readFileSync(join(templatesDir, template), "utf-8"), subs, flags);
118
+ writeFileSync(outputPath, rendered);
119
+ // `reason: "force"` only when we actually clobbered a pre-existing
120
+ // file — distinct from a clean first-time write. The existsSync above
121
+ // is captured BEFORE the write so the flag reflects pre-state.
122
+ files.push({
123
+ path: outputPath,
124
+ written: true,
125
+ ...(preExisted && options.force ? { reason: "force" as const } : {}),
126
+ });
127
+ }
128
+
129
+ return { destination: deployDir, files, detected };
130
+ }
131
+
132
+ function detectOptionalSurfaces(sourceDir: string): ScaffoldDeployDetected {
133
+ const hasSeeds = existsSync(join(sourceDir, "seeds"));
134
+ let hasPrivateGhPackages = false;
135
+ const pkgJsonPath = join(sourceDir, "package.json");
136
+ if (existsSync(pkgJsonPath)) {
137
+ try {
138
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")) as {
139
+ dependencies?: Record<string, string>;
140
+ devDependencies?: Record<string, string>;
141
+ };
142
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
143
+ hasPrivateGhPackages = Object.keys(allDeps).some((d) =>
144
+ d.startsWith("@cosmicdriftgamestudio/"),
145
+ );
146
+ } catch {
147
+ // malformed package.json — assume no private packages, app-author can override via Dockerfile
148
+ }
149
+ }
150
+ return { hasSeeds, hasPrivateGhPackages };
151
+ }
152
+
153
+ function render(
154
+ source: string,
155
+ subs: Readonly<Record<string, string>>,
156
+ flags: Readonly<Record<string, boolean>>,
157
+ ): string {
158
+ // Step 1: handle mustache-style block conditionals `{{#flag}}...{{/flag}}`
159
+ // (multiline-aware via [\s\S]). When flag is truthy → keep inner content;
160
+ // when falsy → strip the entire block (including the surrounding line so
161
+ // we don't leave blank lines in the rendered Dockerfile).
162
+ let result = source.replace(
163
+ /^[ \t]*\{\{#([a-z][a-zA-Z0-9]*)\}\}\n([\s\S]*?)\n[ \t]*\{\{\/\1\}\}[ \t]*\n?/gm,
164
+ (_full, key: string, inner: string) => {
165
+ const flag = flags[key];
166
+ if (flag === undefined) {
167
+ throw new Error(`scaffoldDeploy.render: unknown block-flag "{{#${key}}}"`);
168
+ }
169
+ return flag ? `${inner}\n` : "";
170
+ },
171
+ );
172
+
173
+ // Step 2: handle plain `{{key}}` substitutions. Pattern is intentionally
174
+ // narrow: lowercase-leading identifier followed by alphanumerics. That
175
+ // excludes Docker/Go template syntax like `{{.Name}}` (leading `.`)
176
+ // which appears verbatim in the migrate-step.sh shell snippet.
177
+ result = result.replace(/\{\{([a-z][a-zA-Z0-9]*)\}\}/g, (full, key: string) => {
178
+ const value = subs[key];
179
+ if (value === undefined) {
180
+ throw new Error(`scaffoldDeploy.render: unknown placeholder "${full}"`);
181
+ }
182
+ return value;
183
+ });
184
+
185
+ return result;
186
+ }
@@ -0,0 +1,42 @@
1
+ # Per-Dockerfile .dockerignore — Buildkit reads this when the
2
+ # Dockerfile lives next to it (`deploy/Dockerfile` + `deploy/Dockerfile.dockerignore`).
3
+ #
4
+ # Keeps the build context lean: source-only excludes (tests, env files,
5
+ # VCS metadata) plus the workspace-internal artefacts that the build
6
+ # stage regenerates from scratch.
7
+
8
+ # VCS + editor
9
+ .git
10
+ .github
11
+ .gitignore
12
+ .vscode
13
+ .idea
14
+
15
+ # Local env + secrets — must NEVER leak into a layer
16
+ .env
17
+ .env.local
18
+ .env.*.local
19
+ **/.env
20
+ *.pem
21
+ *.key
22
+
23
+ # Tests + coverage (not in production image)
24
+ **/__tests__
25
+ **/*.test.ts
26
+ **/*.integration.ts
27
+ coverage
28
+ **/*.tsbuildinfo
29
+
30
+ # Docs (not in image)
31
+ docs
32
+ CHANGELOG.md
33
+ CODE_OF_CONDUCT.md
34
+ CONTRIBUTING.md
35
+
36
+ # Workspace-internal — `dist/` is build output; runtime stage copies
37
+ # fresh dist/ from the build stage. Local dist/ (from a dev build)
38
+ # stays out.
39
+ node_modules
40
+ .yarn
41
+ **/node_modules
42
+ **/dist
@@ -0,0 +1,130 @@
1
+ # syntax=docker/dockerfile:1.7
2
+ #
3
+ # Production-Image for {{appName}}.
4
+ #
5
+ # Server-Bundle variant — the runtime image doesn't know about the
6
+ # monorepo any more, only:
7
+ #
8
+ # dist/ ← Client build (kumiko-build)
9
+ # dist-server/ ← Server bundle (bun build) + minimal package.json
10
+ # drizzle/ ← Migrations
11
+ # drizzle.config.ts ← drizzle-kit config (for `bunx drizzle-kit migrate`)
12
+ #
13
+ # Size: ~250 MB incl. drizzle-kit for the pre-deploy migrate step.
14
+ # Build context: repository root (monorepo is used in the build stage).
15
+
16
+ ARG BUN_VERSION=1.2.20
17
+ # Build identity — passed in by the CI workflow via --build-arg. Defaults
18
+ # for local container builds.
19
+ ARG BUILD_VERSION=dev
20
+ ARG BUILD_TIME=unknown
21
+ {{#hasPrivateGhPackages}}
22
+ # Private @cosmicdriftgamestudio/* GH-Packages dep detected — yarn-4
23
+ # reads the token via `npmAuthToken: "${GITHUB_TOKEN:-…}"` in .yarnrc.yml.
24
+ # Without this build-arg, yarn install aborts with YN0041 anonymous-auth.
25
+ ARG GITHUB_TOKEN=
26
+ {{/hasPrivateGhPackages}}
27
+
28
+ # ----- build: produces dist/ + dist-server/ ---------------------------------
29
+ # Build base is node-alpine (not bun-alpine) because the repo uses yarn 4 as
30
+ # its package manager — `link:./.kumiko` (@app/define codegen output) is
31
+ # yarn-4's symlink protocol, which bun interprets differently. Node ships
32
+ # corepack (yarn 4 via packageManager field); we pull bun via npm for
33
+ # `bun run build`. Runtime stage stays pure bun.
34
+ FROM node:20-alpine AS build
35
+ {{#hasPrivateGhPackages}}
36
+ # Multi-stage ARG inheritance: globals declared before FROM need an
37
+ # `ARG <name>` line in every stage that uses them. Without this,
38
+ # ${GITHUB_TOKEN} below resolves to empty and yarn install hits YN0041.
39
+ ARG GITHUB_TOKEN
40
+ {{/hasPrivateGhPackages}}
41
+ WORKDIR /app
42
+
43
+ RUN corepack enable && npm install -g bun@${BUN_VERSION}
44
+
45
+ # Standalone-repo layout: @cosmicdrift/* pulled from NPM, no workspace:*
46
+ # refs. Manifests first for Docker layer cache (yarn install only
47
+ # invalidates on dep change).
48
+ COPY package.json yarn.lock .yarnrc.yml ./
49
+
50
+ # YARN_ENABLE_INLINE_BUILDS=true: postinstall stdout/stderr inline in the
51
+ # install output. Without this, yarn 4 hides logs in /tmp/xfs-*/build.log
52
+ # (invisible in the Docker layer output) and the CI operator guesses why
53
+ # the install failed.
54
+ #
55
+ # `link:./.kumiko` (@app/define in package.json): yarn 4 creates a
56
+ # dangling symlink; install does NOT fail. `bun run build` below calls
57
+ # `runCodegen` from @cosmicdrift/kumiko-dev-server (see
58
+ # bin/kumiko-build.ts), which writes .kumiko/define.ts and turns the
59
+ # symlink real before the bundle is built.
60
+ ENV YARN_ENABLE_INLINE_BUILDS=true
61
+ {{#hasPrivateGhPackages}}
62
+ # Re-export GITHUB_TOKEN as env so yarn-4's `${GITHUB_TOKEN:-…}` expansion
63
+ # in .yarnrc.yml finds it during the install step.
64
+ ENV GITHUB_TOKEN=${GITHUB_TOKEN}
65
+ {{/hasPrivateGhPackages}}
66
+ RUN yarn install --immutable
67
+
68
+ COPY . .
69
+ RUN bun run build
70
+
71
+ # ----- runtime: bun-alpine, knows only the bundle artefact ------------------
72
+ FROM oven/bun:${BUN_VERSION}-alpine AS runtime
73
+ WORKDIR /app
74
+
75
+ RUN addgroup -S app && adduser -S app -G app
76
+
77
+ # Server bundle + its minimal package.json. `bun install --production`
78
+ # only pulls the actual externals.
79
+ COPY --from=build --chown=app:app /app/dist-server ./
80
+ RUN bun install --production
81
+
82
+ # Client build (static assets) — served by runProdApp.staticDir.
83
+ COPY --from=build --chown=app:app /app/dist ./dist
84
+
85
+ # Drizzle artefacts for migrate apply: drizzle-kit reads drizzle.config.ts
86
+ # (bundled — inlined kumikoDrizzleConfig, no dev-server pkg needed) and
87
+ # applies the SQL migrations from drizzle/migrations/. schema.ts is NOT
88
+ # loaded (apply is SQL-only; generate reads schema).
89
+ COPY --from=build --chown=app:app /app/dist-server/drizzle.config.ts ./drizzle.config.ts
90
+ COPY --from=build --chown=app:app /app/drizzle ./drizzle
91
+
92
+ {{#hasSeeds}}
93
+ # ES-Operations seed migrations — runtime-loaded via dynamic import. Bun
94
+ # does NOT bundle these into dist-server/server.js (await import on a
95
+ # dynamic path), so the seeds/ tree is copied raw. Bun's runtime
96
+ # transpiles the TS files on the fly.
97
+ #
98
+ # This block is emitted because scaffoldDeploy detected a `seeds/`
99
+ # directory at the source root. Apps without it skip the block entirely.
100
+ COPY --from=build --chown=app:app /app/seeds ./seeds
101
+ {{/hasSeeds}}
102
+
103
+ USER app
104
+
105
+ ENV NODE_ENV=production
106
+ ENV PORT={{port}}
107
+ # kumiko.ts migrate path resolves drizzle-kit relative to KUMIKO_REPO_ROOT
108
+ # (default is parent-of-import.meta.dir, which in a bundle points to the
109
+ # wrong directory). /app is the app root + node_modules parent here.
110
+ ENV KUMIKO_REPO_ROOT=/app
111
+ # kumiko.ts expects INIT_CWD as the app workspace (drizzle.config.ts +
112
+ # drizzle/ live per app). In the bundle container, /app is that workspace.
113
+ ENV INIT_CWD=/app
114
+ # kumiko.ts would otherwise load drizzle/migration-hooks.ts as an
115
+ # unbundled .ts — that fails because dev-server and framework imports
116
+ # can't be resolved from the runtime node_modules. Override to the
117
+ # bundled equivalent in /app/migration-hooks.js.
118
+ ENV KUMIKO_MIGRATION_HOOKS=/app/migration-hooks.js
119
+ # Build identity in the runtime env so /api/version returns it.
120
+ # Built: SHA of the commit at image build time + ISO timestamp.
121
+ ENV BUILD_VERSION=$BUILD_VERSION
122
+ ENV BUILD_TIME=$BUILD_TIME
123
+
124
+ EXPOSE {{port}}
125
+
126
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
127
+ CMD wget --quiet --spider "http://127.0.0.1:${PORT}/health" || exit 1
128
+
129
+ # exec → bun is PID 1 → SIGTERM reaches it directly → graceful shutdown.
130
+ CMD ["sh", "-c", "exec bun run server.js"]
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ # {{appName}} pre-deploy migrate step.
3
+ #
4
+ # Called by the deploy workflow between `compose up -d db redis` and
5
+ # `compose up -d app` via SSH (see `.github/workflows/deploy.yml` or
6
+ # equivalent). Lives on the server under `/srv/{{appName}}/migrate-step.sh`,
7
+ # called via `bash migrate-step.sh`.
8
+ #
9
+ # Task: apply drizzle-kit migrations + auto-rebuild for projection-schema
10
+ # changes BEFORE the app container starts — runProdApp's boot gate would
11
+ # otherwise abort with SchemaDriftError.
12
+ #
13
+ # Network discovery: the _stack network is named `<dirname>_stack`
14
+ # (compose convention). We discover it via `docker network ls` instead
15
+ # of hard-coding.
16
+
17
+ set -euo pipefail
18
+
19
+ STACK_NETWORK=$(docker network ls --format "{{.Name}}" | grep -E "_stack$" | head -1)
20
+ if [ -z "$STACK_NETWORK" ]; then
21
+ echo "No _stack network found — compose project not running?" >&2
22
+ exit 1
23
+ fi
24
+
25
+ # Import .env as bash env. `set -a` marks subsequent variable assignments
26
+ # as auto-export. Bash's built-in parser respects quotes/escapes like
27
+ # compose does, without depending on compose-profile features.
28
+ set -a
29
+ . ./.env
30
+ set +a
31
+
32
+ # DATABASE_URL assumes: db user = appName, db name = appName, host "db"
33
+ # (compose-service-name), port 5432. Adjust to your stack if different.
34
+ # Image tag pinned to :latest — swap to :${BUILD_SHA} for atomic deploys.
35
+ docker run --rm \
36
+ --network "$STACK_NETWORK" \
37
+ -e DATABASE_URL="postgresql://{{appName}}:${DB_PASSWORD}@db:5432/{{appName}}" \
38
+ ghcr.io/{{githubOrg}}/{{appName}}:latest \
39
+ bun /app/kumiko.js migrate apply