@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.
- package/bin/kumiko-build.ts +25 -19
- package/bin/kumiko-init-deploy.ts +21 -0
- package/package.json +6 -5
- package/src/__tests__/init-deploy-cli.test.ts +113 -0
- package/src/__tests__/scaffold-deploy.test.ts +219 -7
- package/src/build.ts +3 -1
- package/src/changes.json +26 -0
- package/src/create-kumiko-server.ts +12 -10
- package/src/index.ts +9 -1
- package/src/init-deploy-cli.ts +148 -0
- package/src/run-dev-app.ts +14 -10
- package/src/scaffold-app-feature.ts +6 -1
- package/src/scaffold-deploy.ts +224 -36
- package/templates/deploy/Dockerfile.template +32 -20
- package/templates/deploy/migrate-step.sh.template +19 -1
|
@@ -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
|
+
}
|
package/src/run-dev-app.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
191
|
-
*
|
|
194
|
+
/** Browser entry module, bundled to /client.js. Mutually exclusive with
|
|
195
|
+
* `clientEntries`. */
|
|
192
196
|
readonly clientEntry?: string;
|
|
193
|
-
/** Multi-
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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-
|
|
199
|
-
*
|
|
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({
|
|
165
|
+
title: createTextField({
|
|
166
|
+
required: true,
|
|
167
|
+
sortable: true,
|
|
168
|
+
personal: false,
|
|
169
|
+
reason: "is_business_data",
|
|
170
|
+
}),
|
|
166
171
|
},
|
|
167
172
|
});
|
|
168
173
|
`;
|
package/src/scaffold-deploy.ts
CHANGED
|
@@ -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
|
|
7
|
-
//
|
|
8
|
-
// from
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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(`
|
|
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 {
|
|
110
|
-
const
|
|
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
|
|
187
|
+
files.push({ path, written: false, reason: "exists" });
|
|
114
188
|
continue;
|
|
115
189
|
}
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
147
|
-
//
|
|
148
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
|
|
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
|
-
#
|
|
31
|
-
ARG
|
|
32
|
-
ENV 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
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
|
|
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 —
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
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
|