@ultimat3/cli 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ // `x build --target static` — the `site/` surface, rendered once and written as files a CDN or an
2
+ // object store can serve with no process behind it. Enumeration, hashing and the output path are
3
+ // `@ultimat3/render`'s `renderStatic`; the document is the one `x dev` serves. This file decides
4
+ // only which routes qualify and where the bytes land.
5
+
6
+ import { join } from 'node:path';
7
+ import type { RouteEntry } from '@ultimat3/render';
8
+ import { renderStatic, routeEntries } from '@ultimat3/render';
9
+ import { loadApp } from './app-load';
10
+ import { appManifest } from './app-manifest';
11
+ import { routeDocument } from './dev-render';
12
+
13
+ /**
14
+ * `static` only. `isr` revalidates and `ssr`/`stream`/`spa` need a process, so writing any of them
15
+ * to disk would publish a page whose staleness nothing can correct — and the route already
16
+ * declared which of the five it is.
17
+ */
18
+ export const isPrerenderable = (entry: RouteEntry): boolean => entry.config.render === 'static';
19
+
20
+ export interface PrerenderOptions {
21
+ readonly root: string;
22
+ readonly out: string;
23
+ /** Origin the rendered `<head>` builds canonical and og:url from. */
24
+ readonly origin?: string;
25
+ }
26
+
27
+ export interface PrerenderedPage {
28
+ readonly path: string;
29
+ /** Relative to `out`, POSIX, as `renderStatic` computed it. */
30
+ readonly file: string;
31
+ readonly hash: string;
32
+ readonly bytes: number;
33
+ }
34
+
35
+ export interface PrerenderReport {
36
+ readonly out: string;
37
+ readonly buildId: string;
38
+ readonly pages: readonly PrerenderedPage[];
39
+ /** Routes that exist and are not static. Reported, so "only 2 pages" is never a mystery. */
40
+ readonly skipped: readonly string[];
41
+ }
42
+
43
+ export const DEFAULT_ORIGIN = 'https://localhost';
44
+
45
+ export async function prerenderSite(options: PrerenderOptions): Promise<PrerenderReport> {
46
+ // The same load `x dev` and `x manifest` perform: importing the app's modules IS what fills the
47
+ // route registry, so there is no route table to prerender before this runs.
48
+ await loadApp(options.root);
49
+ const buildId = (await appManifest(options.root)).manifest.buildId;
50
+ const origin = options.origin ?? DEFAULT_ORIGIN;
51
+ const pages: PrerenderedPage[] = [];
52
+ const skipped: string[] = [];
53
+
54
+ for (const entry of routeEntries()) {
55
+ if (!isPrerenderable(entry)) {
56
+ skipped.push(entry.path);
57
+ continue;
58
+ }
59
+ const artifacts = await renderStatic(
60
+ entry,
61
+ ({ path, params }) => routeDocument(entry, { url: new URL(path, origin).href, params }),
62
+ { buildId },
63
+ );
64
+ for (const artifact of artifacts) {
65
+ const file = join(options.out, artifact.outputPath);
66
+ const bytes = await Bun.write(file, artifact.html);
67
+ pages.push({ path: artifact.path, file: artifact.outputPath, hash: artifact.hash, bytes });
68
+ }
69
+ }
70
+ return { out: options.out, buildId, pages, skipped };
71
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,200 @@
1
+ // What a container starts. `apps/web/server.ts` is three lines that call `runRole`, so the boot a
2
+ // production process performs is framework code with tests rather than app code the author has to
3
+ // get right — and it is the SAME code `x dev` runs, minus the watcher, minus `/_x`, minus
4
+ // `dev: true`. The only production-shaped decisions live here: which role, which port, and the
5
+ // fact that a container must bind every interface.
6
+
7
+ import { listActions, toRoute } from '@ultimat3/action';
8
+ import type { Role } from '@ultimat3/core';
9
+ import { isRole, logger, ROLES } from '@ultimat3/core';
10
+ import { type MigrationReport, migrate } from '@ultimat3/db';
11
+ import type { Route } from '@ultimat3/http';
12
+ import { loadApp } from './app-load';
13
+ import { appManifest } from './app-manifest';
14
+ import { assetRoutes } from './dev-assets';
15
+ import { startQueue } from './dev-queue';
16
+ import { appRoutes } from './dev-render';
17
+ import type { RunningRoles, WebBinding } from './dev-roles';
18
+ import { startRoles } from './dev-roles';
19
+ import type { RunningServices } from './dev-runtime';
20
+ import { startServices } from './dev-runtime';
21
+ import type { Env } from './dev-services';
22
+ import { resolveServices } from './dev-services';
23
+ import { PortInvalidError, RoleUnknownError } from './errors';
24
+ import { holdUntilShutdown } from './hold';
25
+ import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
26
+ import { readMigrations } from './migrations';
27
+
28
+ export const DEFAULT_PORT = 3000;
29
+
30
+ /** Every interface. A container bound to loopback is unreachable through its own port mapping. */
31
+ export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
32
+
33
+ /**
34
+ * `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
35
+ * to `web` would start a process that serves nothing the operator asked for and reports healthy.
36
+ */
37
+ export function roleFromEnv(env: Env): Role {
38
+ const raw = env['ROLE'] ?? 'web';
39
+ if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
40
+ return raw;
41
+ }
42
+
43
+ /**
44
+ * `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
45
+ * partially-parsed port is a deploy that binds somewhere nobody asked for.
46
+ */
47
+ function portValue(env: Env, name: string, fallback: number): number {
48
+ const raw = env[name];
49
+ if (raw === undefined || raw.trim().length === 0) return fallback;
50
+ const port = Number(raw.trim());
51
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
52
+ throw new PortInvalidError({ value: raw, name });
53
+ return port;
54
+ }
55
+
56
+ /** Every PaaS injects `PORT` and routes traffic to exactly it. */
57
+ export function portFromEnv(env: Env): number {
58
+ return portValue(env, 'PORT', DEFAULT_PORT);
59
+ }
60
+
61
+ /**
62
+ * The scrape port, deliberately its own env var and not `PORT + n`: an operator who moves the app
63
+ * port must not silently move the port their Prometheus is configured against, and the roles that
64
+ * set no `PORT` at all — `worker`, `scheduler`, `replicator` — still need this one.
65
+ */
66
+ export function metricsPortFromEnv(env: Env): number {
67
+ return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
68
+ }
69
+
70
+ export interface ServeOptions {
71
+ readonly root: string;
72
+ readonly env: Env;
73
+ /** Overrides `ROLE`; `runRole` reads the environment when this is absent. */
74
+ readonly role?: Role;
75
+ /** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
76
+ readonly port?: number;
77
+ /** Overrides `METRICS_PORT`, on the same terms. */
78
+ readonly metricsPort?: number;
79
+ }
80
+
81
+ export interface ServedApp {
82
+ readonly kind: 'served';
83
+ readonly role: Role;
84
+ /** `http://…` for the web role; null for the roles that open no HTTP socket. */
85
+ readonly url: string | null;
86
+ readonly buildId: string;
87
+ readonly running: RunningRoles;
88
+ readonly runtime: RunningServices;
89
+ stop(): Promise<void>;
90
+ }
91
+
92
+ export interface MigratedApp {
93
+ readonly kind: 'migrated';
94
+ readonly role: 'migrate';
95
+ readonly report: MigrationReport;
96
+ }
97
+
98
+ export type StartedApp = ServedApp | MigratedApp;
99
+
100
+ /**
101
+ * The release phase, as a role. `migrate` is not a server: it applies the app's own migrations
102
+ * through `@ultimat3/db`'s ledger — advisory lock, per-migration checksum, app-version fence — and
103
+ * exits, so a platform that runs one container to completion before the rest start (Heroku's
104
+ * release phase, a compose `service_completed_successfully`, a Kubernetes Job) has exactly one
105
+ * thing to run and no framework-specific flag to learn.
106
+ *
107
+ * It boots the queue, not the whole runtime: this role touches the database and nothing else, and
108
+ * `startQueue` is what installs `db()` for `migrate()` to find.
109
+ */
110
+ export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
111
+ const queue = await startQueue(resolveServices(options.root, options.env));
112
+ try {
113
+ const migrations = await readMigrations(options.root);
114
+ const report = await migrate({
115
+ migrations,
116
+ ...(options.env['APP_VERSION'] === undefined
117
+ ? {}
118
+ : { appVersion: options.env['APP_VERSION'] }),
119
+ });
120
+ logger.info('ultimate migrate applied', {
121
+ applied: report.applied.length,
122
+ available: migrations.length,
123
+ appVersion: report.appVersion,
124
+ });
125
+ return { kind: 'migrated', role: 'migrate', report };
126
+ } finally {
127
+ await queue.stop();
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Boot order is `x dev`'s, for the reason `x dev` gives: services, then the app's own modules
133
+ * (importing them IS the registration), then the role that serves what they registered. The route
134
+ * table is the same three contributions minus the dashboard — a `/_x` in production would expose
135
+ * the app's policy matrix, its outbox and its spans to the internet.
136
+ */
137
+ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
138
+ const role = options.role ?? roleFromEnv(options.env);
139
+ const runtime = await startServices(resolveServices(options.root, options.env), options.env);
140
+ // Importing the app's modules IS the registration: every route, action and job below is
141
+ // whatever this call put in the registries.
142
+ await loadApp(options.root);
143
+ // The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
144
+ // answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only
145
+ // when unstamped, because a stamped image already paid for it at build time and a replica's boot
146
+ // should not repeat it — the load above is the part every boot needs either way.
147
+ const stamped = options.env['BUILD_ID'];
148
+ const buildId =
149
+ stamped !== undefined && stamped.length > 0
150
+ ? stamped
151
+ : (await appManifest(options.root)).manifest.buildId;
152
+ const routes: readonly Route[] = [
153
+ ...listActions().map(toRoute),
154
+ ...assetRoutes({ root: options.root, storage: runtime.storage }),
155
+ ...appRoutes({ buildId }),
156
+ ];
157
+ const port = options.port ?? portFromEnv(options.env);
158
+ // An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
159
+ // fixed 9090 would fail the next suite to boot beside it. An environment that names the port
160
+ // still wins — that is the deploy talking.
161
+ const metricsPort =
162
+ options.metricsPort ??
163
+ (port === 0 && options.env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(options.env));
164
+ const running = await startRoles({
165
+ roles: [role],
166
+ port,
167
+ metricsPort,
168
+ buildId,
169
+ runtime,
170
+ routes,
171
+ env: options.env,
172
+ http: CONTAINER_BINDING,
173
+ });
174
+ return {
175
+ kind: 'served',
176
+ role,
177
+ url: running.url,
178
+ buildId,
179
+ running,
180
+ runtime,
181
+ async stop() {
182
+ await running.stop();
183
+ await runtime.stop();
184
+ },
185
+ };
186
+ }
187
+
188
+ /**
189
+ * What `apps/web/server.ts` calls. Returns for `migrate` — the process is meant to exit — and
190
+ * holds for every other role until core's drain completes, so SIGTERM from a rolling restart takes
191
+ * the three-phase path (stop accepting, finish in-flight, close) instead of killing a query.
192
+ */
193
+ export async function runRole(options: ServeOptions): Promise<StartedApp> {
194
+ const role = options.role ?? roleFromEnv(options.env);
195
+ if (role === 'migrate') return runMigrations({ ...options, role });
196
+ const app = await serveApp({ ...options, role });
197
+ logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
198
+ await holdUntilShutdown('serve', () => app.stop())();
199
+ return app;
200
+ }
@@ -18,6 +18,7 @@ export { resourceFiles } from './resource';
18
18
  export type { RouteOptions, Surface } from './route';
19
19
  export { routeFiles } from './route';
20
20
  export { appFiles } from './scaffold-app';
21
+ export { containerFiles } from './scaffold-container';
21
22
  export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
22
23
  export { i18nIndex } from './scaffold-i18n';
23
24
  export { repoFiles } from './scaffold-repo';
@@ -280,6 +280,59 @@ export function AdminHome() {
280
280
  }
281
281
  `;
282
282
 
283
+ // The two entry files a deploy needs. Both are deliberately thin: which role a container is, which
284
+ // port it binds, how it drains and what a static build enumerates are the framework's answers, so
285
+ // an upgrade moves them without a codemod in every app that ever shipped.
286
+
287
+ const server =
288
+ (): string => `// The production entry. \`docker/Dockerfile\` starts this, and \`x build --target binary\` compiles it.
289
+ // ROLE selects what this process is — web, sync, worker, scheduler, replicator, or migrate, which
290
+ // applies the migrations and exits. PORT is bound on every interface, because a container bound to
291
+ // localhost is unreachable through its own port mapping.
292
+
293
+ import { join } from 'node:path';
294
+ import { runRole } from '@ultimat3/cli';
295
+
296
+ /**
297
+ * Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
298
+ * A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
299
+ * holds this module's bundled imports and none of the app's source, and the framework's registries
300
+ * are filled by scanning that source at boot. So a binary reads its root from the directory it is
301
+ * started in — it is a launcher for an app tree, not a self-contained copy of one.
302
+ */
303
+ const root = import.meta.dir.startsWith('/$bunfs')
304
+ ? process.cwd()
305
+ : join(import.meta.dir, '..', '..');
306
+
307
+ // Guarded, because the framework's module scan imports every file under apps/*/ to fill its
308
+ // registries — an unguarded boot would start a server inside \`x verify\`.
309
+ if (import.meta.main) {
310
+ await runRole({ root, env: Bun.env });
311
+ }
312
+ `;
313
+
314
+ const prerender =
315
+ (): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
316
+ // file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
317
+ // behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
318
+
319
+ import { join } from 'node:path';
320
+ import { prerenderSite } from '@ultimat3/cli';
321
+
322
+ const root = join(import.meta.dir, '..', '..');
323
+ const flag = Bun.argv.indexOf('--out');
324
+ const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
325
+ // SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
326
+ const origin = Bun.env['SITE_ORIGIN'];
327
+
328
+ if (import.meta.main) {
329
+ const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
330
+ await Bun.stdout.write(
331
+ \`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped })}\\n\`,
332
+ );
333
+ }
334
+ `;
335
+
283
336
  const placeholder = (surface: string, app: NameSet): string => `# ${surface}
284
337
 
285
338
  Placeholder. The monorepo shape exists now so adding ${surface} later is a new directory, not a
@@ -297,6 +350,8 @@ export function appFiles(app: NameSet): readonly GeneratedFile[] {
297
350
  return [
298
351
  { path: 'apps/web/package.json', contents: webPackage(app) },
299
352
  { path: 'apps/web/tsconfig.json', contents: tsconfig() },
353
+ { path: 'apps/web/server.ts', contents: server() },
354
+ { path: 'apps/web/prerender.ts', contents: prerender() },
300
355
  { path: 'apps/web/site/icon.png', contents: icon() },
301
356
  { path: 'apps/web/site/page.tsx', contents: sitePage(app) },
302
357
  { path: 'apps/web/site/page.module.scss', contents: siteStyle() },
@@ -0,0 +1,264 @@
1
+ // The container half of what `x new` writes: the image, what to ignore when building it, the
2
+ // production topology, and the one page that explains how a platform runs the release phase.
3
+ // Split from scaffold-docs.ts because these four are one subject and that file is another.
4
+ //
5
+ // Axiom 7 in file form. Nothing here names a cloud: `$PORT`, `0.0.0.0`, `/readyz` and a
6
+ // run-to-completion migrate step are conventions every container platform shares — a Heroku
7
+ // buildpack, a Render blueprint or a fly.toml would be the primitive that never ships.
8
+
9
+ import type { GeneratedFile, NameSet } from './naming';
10
+
11
+ const dockerfile = (
12
+ app: NameSet,
13
+ ): string => `# One image, every role. ROLE selects behaviour at start, so there is exactly one artifact to
14
+ # promote — the image that passed staging is the image production runs.
15
+ #
16
+ # x build --target docker --tag ${app.kebab}:$(git rev-parse --short HEAD)
17
+ # docker run --rm -e ROLE=migrate -e DATABASE_URL=... ${app.kebab}:... # release phase
18
+ # docker run -e ROLE=web -e PORT=8080 -p 8080:8080 -e DATABASE_URL=... ${app.kebab}:...
19
+ #
20
+ # syntax=docker/dockerfile:1
21
+
22
+ # ---------- deps: runtime dependencies only, cached on the workspace manifests ----------
23
+ FROM oven/bun:1.3-alpine AS deps
24
+ WORKDIR /app
25
+ COPY package.json bun.lock ./
26
+ # The workspace members' manifests are what \`bun install\` resolves against; their sources are not.
27
+ COPY apps ./apps
28
+ COPY packages ./packages
29
+ RUN bun install --frozen-lockfile --production
30
+
31
+ # ---------- runtime ----------
32
+ # No build stage and no second gate: \`x verify\` is the gate and \`x build\` runs the static steps
33
+ # before it ever calls \`docker build\`. Re-running typecheck and lint here would need the
34
+ # devDependencies the \`--production\` install above deliberately leaves out — which is exactly how
35
+ # a build stage came to run \`tsc\` and \`biome\` against a tree that had neither.
36
+ FROM oven/bun:1.3-alpine AS runtime
37
+ WORKDIR /app
38
+ COPY --from=deps /app/node_modules ./node_modules
39
+ COPY . .
40
+
41
+ # The immutable content hash this image serves. Stamped by CI (\`--build-arg BUILD_ID=$(git rev-parse HEAD)\`);
42
+ # without one the server computes the same hash from the manifest at boot. Never \`latest\`.
43
+ ARG BUILD_ID=
44
+ ENV NODE_ENV=production \\
45
+ ROLE=web \\
46
+ PORT=3000 \\
47
+ BUILD_ID=\${BUILD_ID}
48
+
49
+ # Documentation only — the platform decides the real port and injects it as PORT. The server binds
50
+ # whatever arrives, on 0.0.0.0, because a container bound to localhost is unreachable through its
51
+ # own port mapping, its load balancer and every health probe alike.
52
+ EXPOSE 3000
53
+
54
+ # Every role serves /healthz and /readyz. /readyz flips to 503 on SIGTERM *before* the socket
55
+ # closes, so a rolling restart drains in-flight work instead of dropping it.
56
+ HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
57
+ bun --eval "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
58
+
59
+ # \`.x/\` is where any binding that is still embedded keeps its state — the local storage disk, and
60
+ # PGlite if DATABASE_URL is unset. Owned by the runtime user, because /app is not: the alternative
61
+ # is a non-root process failing at boot on a directory it is the only one that ever writes.
62
+ RUN mkdir -p /app/.x && chown -R bun:bun /app/.x
63
+
64
+ USER bun
65
+ # apps/web/server.ts reads ROLE and PORT and nothing else. \`migrate\` applies the migrations and
66
+ # exits; every other role serves until SIGTERM.
67
+ ENTRYPOINT ["bun", "apps/web/server.ts"]
68
+ `;
69
+
70
+ /**
71
+ * BuildKit prefers `<dockerfile>.dockerignore` over the context root's, so this sits beside the
72
+ * Dockerfile. Without it `COPY . .` ships `node_modules` and `.x/` — a stale host `node_modules`
73
+ * would shadow the `--production` install the deps stage just made.
74
+ */
75
+ const dockerignore = (): string => `node_modules
76
+ **/node_modules
77
+ **/.x
78
+ **/dist
79
+ **/*.tsbuildinfo
80
+ .git
81
+ .env
82
+ .env.*.local
83
+ coverage
84
+ **/test-results
85
+ **/playwright-report
86
+ `;
87
+
88
+ const composeProd = (
89
+ app: NameSet,
90
+ ): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
91
+ # What \`x deploy --method compose\` runs. migrate runs to completion before anything serves.
92
+ #
93
+ # IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
94
+ name: ${app.kebab}
95
+
96
+ x-image: &image
97
+ image: \${IMAGE:-${app.kebab}:dev}
98
+ env_file: [../.env.production]
99
+ restart: unless-stopped
100
+ stop_grace_period: 30s # SIGTERM → drain in-flight requests, jobs and sockets
101
+ depends_on:
102
+ db: { condition: service_healthy }
103
+
104
+ services:
105
+ db:
106
+ image: postgres:17-alpine
107
+ environment:
108
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
109
+ POSTGRES_DB: ${app.kebab}
110
+ volumes: ['pgdata:/var/lib/postgresql/data']
111
+ healthcheck:
112
+ test: ['CMD-SHELL', 'pg_isready -U postgres']
113
+ interval: 5s
114
+ restart: unless-stopped
115
+
116
+ # The release phase. Applies pending migrations under an advisory lock and exits; every serving
117
+ # role waits for it to complete, so no replica ever serves against a schema it does not ship.
118
+ migrate:
119
+ <<: *image
120
+ environment: [ROLE=migrate]
121
+ restart: 'no'
122
+
123
+ web:
124
+ <<: *image
125
+ environment: [ROLE=web]
126
+ depends_on:
127
+ db: { condition: service_healthy }
128
+ migrate: { condition: service_completed_successfully }
129
+ deploy: { replicas: 2 } # stateless: scales on RPS
130
+ ports: ['3000:3000']
131
+
132
+ sync:
133
+ <<: *image
134
+ environment: [ROLE=sync]
135
+ depends_on:
136
+ db: { condition: service_healthy }
137
+ migrate: { condition: service_completed_successfully }
138
+ deploy: { replicas: 1 } # scales on concurrent websockets; no sticky sessions
139
+ ports: ['3001:3001']
140
+
141
+ worker:
142
+ <<: *image
143
+ environment: [ROLE=worker]
144
+ depends_on:
145
+ db: { condition: service_healthy }
146
+ migrate: { condition: service_completed_successfully }
147
+ deploy: { replicas: 1 } # scales on queue depth
148
+
149
+ scheduler:
150
+ <<: *image
151
+ environment: [ROLE=scheduler]
152
+ depends_on:
153
+ db: { condition: service_healthy }
154
+ migrate: { condition: service_completed_successfully }
155
+ deploy: { replicas: 1 } # fixed 1; leadership is a Postgres advisory lock
156
+
157
+ volumes:
158
+ pgdata:
159
+ `;
160
+
161
+ const readme = (app: NameSet): string => `# docker
162
+
163
+ One image, every role. \`ROLE\` selects behaviour at start, so there is one artifact to promote and
164
+ nothing to rebuild between staging and production.
165
+
166
+ | Role | Does | Listens |
167
+ |---|---|---|
168
+ | \`web\` | HTTP: pages, actions, assets | \`$PORT\` (default 3000) |
169
+ | \`sync\` | websockets for live queries | \`$PORT + 1\` |
170
+ | \`worker\` | the job queue | — |
171
+ | \`scheduler\` | cron tasks; leadership is a Postgres advisory lock | — |
172
+ | \`replicator\` | the logical replication slot, exactly one per database | — |
173
+ | \`migrate\` | applies pending migrations and **exits** | — |
174
+
175
+ Every serving role answers \`/healthz\` and \`/readyz\`. \`/readyz\` flips to 503 on \`SIGTERM\` before the
176
+ socket closes, which is what makes a rolling restart drain instead of drop.
177
+
178
+ ## Build and run
179
+
180
+ \`\`\`sh
181
+ x build --target docker --tag ${app.kebab}:dev
182
+ docker run --rm -e ROLE=migrate -e DATABASE_URL=postgres://... ${app.kebab}:dev
183
+ docker run -p 3000:3000 -e DATABASE_URL=postgres://... ${app.kebab}:dev
184
+ \`\`\`
185
+
186
+ ## One box, every role
187
+
188
+ \`\`\`sh
189
+ docker compose -f docker/docker-compose.prod.yml up -d # db → migrate → the rest
190
+ x deploy --image ${app.kebab}:dev --dry-run --json # the same plan, printed
191
+ \`\`\`
192
+
193
+ ## The other two build targets
194
+
195
+ \`\`\`sh
196
+ x build --target static --out dist/static # one HTML file per \`render: 'static'\` route
197
+ x build --target binary --out dist/app # a single executable, no Bun install needed
198
+ \`\`\`
199
+
200
+ The binary bundles the framework, not the app: the registries are filled by scanning
201
+ \`apps/*/{site,app,api,shared}\` at boot, so it is a launcher that must be **started from the app
202
+ root**, with the source tree beside it. The image is the self-contained artifact.
203
+
204
+ ## A PaaS (Heroku, Render, Fly, Railway, Cloud Run, App Runner…)
205
+
206
+ The framework ships **no** platform primitives — no buildpack, no \`app.json\`, no \`fly.toml\`, no
207
+ adapter. It does not need to: every one of these platforms builds a Dockerfile and every one of
208
+ them expects the same three things, which this image already does.
209
+
210
+ | The platform does | The image does |
211
+ |---|---|
212
+ | injects \`PORT\` and routes traffic to it | \`apps/web/server.ts\` binds exactly \`$PORT\`, refusing a value that is not a port (\`X_PORT_INVALID\`) rather than defaulting past it |
213
+ | requires the process to bind \`0.0.0.0\` | it binds every interface; loopback would be unreachable from outside the container |
214
+ | polls a health path | \`/readyz\` for "may I have traffic", \`/healthz\` for "am I alive" |
215
+ | sends \`SIGTERM\`, then \`SIGKILL\` after a grace period | drains in three phases: stop accepting, finish in-flight, close |
216
+
217
+ Set \`DATABASE_URL\` and deploy the Dockerfile. That is the whole integration.
218
+
219
+ ### Release-phase migrations — the one way
220
+
221
+ Run **the same image** with \`ROLE=migrate\` before the new release serves traffic. It applies every
222
+ pending migration in \`packages/db/migrations\` under a Postgres advisory lock, records each in the
223
+ \`x_migrations\` ledger with its checksum, and exits 0. Concurrent migrators serialise; a checksum
224
+ that no longer matches an applied migration stops the release instead of corrupting it.
225
+
226
+ | Platform | Where the command goes |
227
+ |---|---|
228
+ | Heroku | \`release: bun apps/web/server.ts\` in \`Procfile\`, with \`ROLE=migrate\` on the release dyno |
229
+ | Render | \`preDeployCommand: ROLE=migrate bun apps/web/server.ts\` |
230
+ | Fly.io | \`[deploy] release_command = "bun apps/web/server.ts"\` with \`ROLE=migrate\` |
231
+ | Railway | a pre-deploy command running the same |
232
+ | Kubernetes | an \`initContainer\` or a \`Job\` on the same image with \`ROLE=migrate\` |
233
+ | Compose | the \`migrate\` service; every other role waits on \`service_completed_successfully\` |
234
+
235
+ There is no \`x db migrate\` in that list on purpose: it is the developer's command and it needs the
236
+ toolchain, while the release phase runs the shipped image and nothing else.
237
+
238
+ ## Environment
239
+
240
+ | Key | Meaning | Unset means |
241
+ |---|---|---|
242
+ | \`ROLE\` | which process this is | \`web\` |
243
+ | \`PORT\` | the port the web role binds | 3000 |
244
+ | \`DATABASE_URL\` | Postgres | embedded PGlite — never in production |
245
+ | \`BUILD_ID\` | the immutable build hash clients are served against | computed from the manifest at boot |
246
+ | \`NATS_URL\` | multi-node realtime transport | in-process fanout, single node only |
247
+ | \`S3_ENDPOINT\` | object storage | a local directory |
248
+
249
+ ## Kubernetes
250
+
251
+ \`x deploy --method helm\` expects a chart at \`docker/helm\`. \`x new\` does not write one — a chart is
252
+ a topology decision, not a scaffold default. Copy \`docker/helm\` from the framework repository, or
253
+ stay on \`--method compose\`.
254
+ `;
255
+
256
+ /** The container files for a new app, in the order a reader meets them. */
257
+ export function containerFiles(app: NameSet): readonly GeneratedFile[] {
258
+ return [
259
+ { path: 'docker/Dockerfile', contents: dockerfile(app) },
260
+ { path: 'docker/Dockerfile.dockerignore', contents: dockerignore() },
261
+ { path: 'docker/docker-compose.prod.yml', contents: composeProd(app) },
262
+ { path: 'docker/README.md', contents: readme(app) },
263
+ ];
264
+ }
@@ -1,8 +1,10 @@
1
1
  // The human-authored half of what `x new` writes: the READMEs, the agent-facing convention files,
2
- // the bin/ shims and the docker directory. Separated from the config half so neither file has to
3
- // be scrolled to find the other — one file, one job applies to templates too.
2
+ // the bin/ shims and the optional dev compose. Separated from the config half so neither file has
3
+ // to be scrolled to find the other — one file, one job applies to templates too. The image, its
4
+ // ignore file, the production topology and the deploy page are `scaffold-container.ts`.
4
5
 
5
6
  import type { GeneratedFile, NameSet } from './naming';
7
+ import { containerFiles } from './scaffold-container';
6
8
 
7
9
  const agents = (app: NameSet): string => `# AGENTS.md
8
10
 
@@ -114,30 +116,6 @@ services:
114
116
  ports: ['9000:9000']
115
117
  `;
116
118
 
117
- const dockerfile = (
118
- app: NameSet,
119
- ): string => `# One image, all roles. ROLE selects behaviour at start; nothing else differs between processes.
120
- FROM oven/bun:1.3-alpine AS deps
121
- WORKDIR /src
122
- COPY package.json bun.lock ./
123
- COPY apps ./apps
124
- COPY packages ./packages
125
- RUN bun install --frozen-lockfile --production
126
-
127
- FROM oven/bun:1.3-alpine AS build
128
- WORKDIR /src
129
- COPY --from=deps /src/node_modules ./node_modules
130
- COPY . .
131
- RUN bunx x build --target binary --out /out/${app.kebab}
132
-
133
- FROM gcr.io/distroless/base-debian12 AS runtime
134
- COPY --from=build /out/${app.kebab} /app/${app.kebab}
135
- ENV ROLE=web PORT=3000
136
- EXPOSE 3000
137
- USER 65532:65532
138
- ENTRYPOINT ["/app/${app.kebab}"]
139
- `;
140
-
141
119
  /** Docs, shims and container files for a new app, in the order a reader meets them. */
142
120
  export function docsFiles(app: NameSet): readonly GeneratedFile[] {
143
121
  return [
@@ -147,8 +125,8 @@ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
147
125
  { path: 'bin/setup', contents: binSetup() },
148
126
  { path: 'bin/dev', contents: binDev() },
149
127
  { path: 'bin/check', contents: binCheck() },
150
- { path: 'docker/Dockerfile', contents: dockerfile(app) },
151
128
  { path: 'docker/docker-compose.dev.yml', contents: composeDev(app) },
129
+ ...containerFiles(app),
152
130
  ];
153
131
  }
154
132