@cosmicdrift/kumiko-dev-server 0.8.1 → 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 +50 -0
- package/package.json +4 -3
- package/src/__tests__/scaffold-deploy.test.ts +170 -0
- package/src/codegen/__tests__/watch.test.ts +6 -3
- package/src/index.ts +6 -0
- package/src/scaffold-deploy.ts +186 -0
- package/templates/deploy/Dockerfile.dockerignore.template +42 -0
- package/templates/deploy/Dockerfile.template +130 -0
- package/templates/deploy/migrate-step.sh.template +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
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
|
+
|
|
3
53
|
## 0.8.1
|
|
4
54
|
|
|
5
55
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "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.
|
|
52
|
-
"@cosmicdrift/kumiko-framework": "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
|
-
|
|
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:
|
|
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:
|
|
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
|