@ultimat3/cli 1.0.0 → 1.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,27 +34,27 @@
34
34
  "dev": "bun run src/bin.ts dev"
35
35
  },
36
36
  "dependencies": {
37
- "@ultimat3/action": "1.0.0",
38
- "@ultimat3/admin": "1.0.0",
39
- "@ultimat3/ai": "1.0.0",
40
- "@ultimat3/cache": "1.0.0",
41
- "@ultimat3/core": "1.0.0",
42
- "@ultimat3/db": "1.0.0",
43
- "@ultimat3/entity": "1.0.0",
44
- "@ultimat3/http": "1.0.0",
45
- "@ultimat3/i18n": "1.0.0",
46
- "@ultimat3/jobs": "1.0.0",
47
- "@ultimat3/mail": "1.0.0",
48
- "@ultimat3/manifest": "1.0.0",
49
- "@ultimat3/mcp": "1.0.0",
50
- "@ultimat3/policy": "1.0.0",
51
- "@ultimat3/pwa": "1.0.0",
52
- "@ultimat3/query": "1.0.0",
53
- "@ultimat3/realtime": "1.0.0",
54
- "@ultimat3/render": "1.0.0",
55
- "@ultimat3/seo": "1.0.0",
56
- "@ultimat3/storage": "1.0.0",
57
- "@ultimat3/testing": "1.0.0",
58
- "@ultimat3/time": "1.0.0"
37
+ "@ultimat3/action": "1.1.0",
38
+ "@ultimat3/admin": "1.1.0",
39
+ "@ultimat3/ai": "1.1.0",
40
+ "@ultimat3/cache": "1.1.0",
41
+ "@ultimat3/core": "1.1.0",
42
+ "@ultimat3/db": "1.1.0",
43
+ "@ultimat3/entity": "1.1.0",
44
+ "@ultimat3/http": "1.1.0",
45
+ "@ultimat3/i18n": "1.1.0",
46
+ "@ultimat3/jobs": "1.1.0",
47
+ "@ultimat3/mail": "1.1.0",
48
+ "@ultimat3/manifest": "1.1.0",
49
+ "@ultimat3/mcp": "1.1.0",
50
+ "@ultimat3/policy": "1.1.0",
51
+ "@ultimat3/pwa": "1.1.0",
52
+ "@ultimat3/query": "1.1.0",
53
+ "@ultimat3/realtime": "1.1.0",
54
+ "@ultimat3/render": "1.1.0",
55
+ "@ultimat3/seo": "1.1.0",
56
+ "@ultimat3/storage": "1.1.0",
57
+ "@ultimat3/testing": "1.1.0",
58
+ "@ultimat3/time": "1.1.0"
59
59
  }
60
60
  }
package/src/app-load.ts CHANGED
@@ -22,6 +22,15 @@ const APP_GLOBS = [
22
22
  'packages/*/src/**/*.ts',
23
23
  ] as const;
24
24
 
25
+ /**
26
+ * The two files that are *entry points*, not app modules: `apps/web/server.ts` starts the process
27
+ * and `apps/web/prerender.ts` runs the build. Importing either registers nothing — and importing
28
+ * `server.ts` deadlocks, because that module's own top-level `await runRole()` is what called this
29
+ * scan, so the dynamic import waits on a module that is waiting on the import. Anchored to the
30
+ * surface root: `apps/web/app/server.ts` is app code and stays in the scan.
31
+ */
32
+ const ENTRY_POINT = /^apps\/[^/]+\/(?:server|prerender)\.tsx?$/;
33
+
25
34
  export interface LoadedApp {
26
35
  readonly root: string;
27
36
  /** App-root-relative POSIX paths of every module that imported, sorted. */
@@ -64,6 +73,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
64
73
  for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
65
74
  if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
66
75
  const file = relative(root, absolute).split(sep).join('/');
76
+ if (ENTRY_POINT.test(file)) continue;
67
77
  let module: Record<string, unknown>;
68
78
  try {
69
79
  module = (await import(absolute)) as Record<string, unknown>;
package/src/cmd-build.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  // `x build --target docker|binary|static` — three targets, no platform primitives. Deploy anywhere
2
2
  // means "anywhere that runs a container or a binary"; nothing here knows the name of a cloud.
3
3
 
4
+ import { existsSync } from 'node:fs';
4
5
  import { join } from 'node:path';
5
6
  import { requireAppRoot } from './app-root';
6
7
  import { runVerify } from './cmd-verify';
7
8
  import type { CliCommand, CommandContext } from './command';
8
- import { UnknownCommandError } from './errors';
9
+ import { BuildEntryMissingError, UnknownCommandError } from './errors';
9
10
  import { execOutput } from './exec';
10
11
  import { msg } from './messages';
11
12
  import type { CommandResult, Finding } from './output';
@@ -26,9 +27,28 @@ export function readTarget(raw: string | undefined): BuildTarget {
26
27
  });
27
28
  }
28
29
 
30
+ /**
31
+ * The one file each target builds from, app-root-relative and POSIX. One table, because `x build`
32
+ * has to refuse a missing entry by name before it spawns anything, and the spawned command has to
33
+ * name the same file — a second copy is how `binary` came to compile a path `x new` never wrote.
34
+ */
35
+ export const BUILD_ENTRY: Readonly<Record<BuildTarget, string>> = {
36
+ docker: 'docker/Dockerfile',
37
+ binary: 'apps/web/server.ts',
38
+ static: 'apps/web/prerender.ts',
39
+ };
40
+
41
+ /** Absolute path of the target's entry, or the error that names the file and what writes it. */
42
+ export function requireEntry(root: string, target: BuildTarget): string {
43
+ const entry = BUILD_ENTRY[target];
44
+ const absolute = join(root, entry);
45
+ if (!existsSync(absolute)) throw new BuildEntryMissingError({ target, entry });
46
+ return absolute;
47
+ }
48
+
29
49
  /** One image for every role; ROLE selects behaviour at start, so there is one artifact to promote. */
30
50
  export function dockerArgs(root: string, tag: string): readonly string[] {
31
- return ['docker', 'build', '-f', join(root, 'docker', 'Dockerfile'), '-t', tag, root];
51
+ return ['docker', 'build', '-f', join(root, BUILD_ENTRY.docker), '-t', tag, root];
32
52
  }
33
53
 
34
54
  export function binaryArgs(root: string, out: string): readonly string[] {
@@ -37,14 +57,14 @@ export function binaryArgs(root: string, out: string): readonly string[] {
37
57
  'build',
38
58
  '--compile',
39
59
  '--minify',
40
- join(root, 'apps', 'web', 'server.ts'),
60
+ join(root, BUILD_ENTRY.binary),
41
61
  '--outfile',
42
62
  out,
43
63
  ];
44
64
  }
45
65
 
46
66
  export function staticArgs(root: string, out: string): readonly string[] {
47
- return ['bun', 'run', join(root, 'apps', 'web', 'prerender.ts'), '--out', out];
67
+ return ['bun', 'run', join(root, BUILD_ENTRY.static), '--out', out];
48
68
  }
49
69
 
50
70
  export function argsFor(
@@ -71,6 +91,10 @@ export const buildCommand: CliCommand = {
71
91
  async run(ctx: CommandContext): Promise<CommandResult> {
72
92
  const root = requireAppRoot('build', ctx.cwd).dir;
73
93
  const target = readTarget(flagString(ctx.args, 'target'));
94
+ // Before the gate, not after: an entry the app does not have cannot be produced by a green
95
+ // typecheck, and eight seconds of `tsc` ahead of "that file does not exist" is eight seconds
96
+ // an agent spends on the wrong question.
97
+ requireEntry(root, target);
74
98
 
75
99
  // Run static verify steps before building.
76
100
  const staticSteps = ['typecheck', 'lint', 'boundaries', 'filesize', 'package-shape', 'errors'];
package/src/dev-render.ts CHANGED
@@ -43,7 +43,11 @@ const headFor = async (entry: RouteEntry, data: DevRouteData): Promise<string> =
43
43
  headFromMeta(await entry.config.meta(data), seoRenderers({ path: new URL(data.url).pathname })),
44
44
  );
45
45
 
46
- async function documentFor(entry: RouteEntry, data: DevRouteData): Promise<string> {
46
+ /**
47
+ * Head + shell for one route render. Exported because the build's prerenderer must emit the same
48
+ * document `x dev` serves — two document builders is how a page that works in dev ships broken.
49
+ */
50
+ export async function routeDocument(entry: RouteEntry, data: DevRouteData): Promise<string> {
47
51
  return shellFor(await headFor(entry, data));
48
52
  }
49
53
 
@@ -63,11 +67,11 @@ async function resultFor(
63
67
  case 'static': {
64
68
  // Not `renderStatic`: that enumerates every prerendered path for the build. A request
65
69
  // names exactly one, and it earns the same content-hashed headers.
66
- const body = await documentFor(entry, data);
70
+ const body = await routeDocument(entry, data);
67
71
  return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
68
72
  }
69
73
  case 'isr': {
70
- const served = await isr.serve(url.pathname, () => documentFor(entry, data));
74
+ const served = await isr.serve(url.pathname, () => routeDocument(entry, data));
71
75
  return served.result;
72
76
  }
73
77
  case 'spa':
@@ -90,7 +94,7 @@ async function resultFor(
90
94
  );
91
95
  }
92
96
  default:
93
- return renderSsr({ entry, params: data.params, url, ctx }, () => documentFor(entry, data), {
97
+ return renderSsr({ entry, params: data.params, url, ctx }, () => routeDocument(entry, data), {
94
98
  buildId: options.buildId,
95
99
  });
96
100
  }
package/src/dev-roles.ts CHANGED
@@ -49,8 +49,24 @@ export interface StartRolesOptions {
49
49
  readonly routes: readonly Route[];
50
50
  /** The process environment, for the roles that resolve a driver from it. */
51
51
  readonly env: Env;
52
+ /**
53
+ * How the web role binds and what it admits about itself. `x dev` keeps the default —
54
+ * loopback, `dev: true`, so a laptop on a café network is not serving the app to the café. A
55
+ * container passes `{ dev: false, hostname: '0.0.0.0' }`: a process bound to `localhost` inside
56
+ * a container is unreachable from the port mapping, the load balancer and every PaaS health
57
+ * probe, which is the same failure in four costumes.
58
+ */
59
+ readonly http?: WebBinding;
52
60
  }
53
61
 
62
+ export interface WebBinding {
63
+ readonly dev: boolean;
64
+ readonly hostname: string;
65
+ }
66
+
67
+ /** Loopback and dev-mode. What `x dev` means, and what a container must override. */
68
+ export const DEV_BINDING: WebBinding = { dev: true, hostname: 'localhost' };
69
+
54
70
  export interface RunningRoles {
55
71
  readonly roles: readonly Role[];
56
72
  /** `http://…` once the web role is up; null when it was not selected. */
@@ -99,15 +115,16 @@ export function selectRoles(flag: string | undefined): readonly Role[] {
99
115
  }
100
116
 
101
117
  function startWeb(options: StartRolesOptions): ServerHandle {
118
+ const binding = options.http ?? DEV_BINDING;
102
119
  return createServer({
103
120
  routes: options.routes,
104
121
  role: 'web',
105
122
  hooks: devHooks(),
106
123
  config: defineHttpConfig({
107
124
  port: options.port,
108
- dev: true,
125
+ dev: binding.dev,
109
126
  buildId: options.buildId,
110
- hostname: 'localhost',
127
+ hostname: binding.hostname,
111
128
  }),
112
129
  }).start();
113
130
  }
@@ -33,10 +33,15 @@ const nonEmpty = (value: string | undefined): string | undefined =>
33
33
  */
34
34
  export function resolveServices(root: string, env: Env): DevServices {
35
35
  const stateDir = join(root, '.x');
36
- mkdirSync(stateDir, { recursive: true });
37
36
  const databaseUrl = nonEmpty(env['DATABASE_URL']);
38
37
  const natsUrl = nonEmpty(env['NATS_URL']);
39
38
  const s3Endpoint = nonEmpty(env['S3_ENDPOINT']);
39
+ // Created only when something will actually live in it. A container whose bindings are all
40
+ // external runs non-root over a read-only app directory, and an unconditional mkdir there is an
41
+ // EACCES at boot for a directory that would have stayed empty.
42
+ if (databaseUrl === undefined || natsUrl === undefined || s3Endpoint === undefined) {
43
+ mkdirSync(stateDir, { recursive: true });
44
+ }
40
45
  return {
41
46
  stateDir,
42
47
  db:
package/src/errors.ts CHANGED
@@ -36,7 +36,14 @@ export const CLI_OWNED_ERROR_CODES = [
36
36
  'X_MANIFEST_STALE',
37
37
  'X_BUDGET_UNMEASURED',
38
38
  'X_BUILD_FAILED',
39
+ 'X_BUILD_ENTRY_MISSING',
39
40
  'X_DEPLOY_FAILED',
41
+ // The two the container's own environment can get wrong. A PaaS injects `PORT` and a supervisor
42
+ // injects `ROLE`; both arrive as strings from outside the app, so both are validated at boot
43
+ // rather than defaulted — a web role that quietly bound 3000 when the platform said 8080 fails
44
+ // its health check with nothing in the log that names the cause.
45
+ 'X_ROLE_UNKNOWN',
46
+ 'X_PORT_INVALID',
40
47
  'X_GENERATE_CONFLICT',
41
48
  'X_PORT_IN_USE',
42
49
  'X_DB_GEN_FAILED',
@@ -100,7 +107,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
100
107
  X_MANIFEST_STALE: 'openapi.json is stale',
101
108
  X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
102
109
  X_BUILD_FAILED: 'x build failed',
110
+ X_BUILD_ENTRY_MISSING: "the build target's entry file is not in the app",
103
111
  X_DEPLOY_FAILED: 'a deploy step failed',
112
+ X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
113
+ X_PORT_INVALID: 'PORT is not a TCP port number',
104
114
  X_GENERATE_CONFLICT: 'a generator would overwrite a file',
105
115
  X_PORT_IN_USE: 'the dev port is taken',
106
116
  X_DB_GEN_FAILED: 'x db gen failed',
@@ -354,6 +364,53 @@ export class FixTargetUnknownError extends UltimateError {
354
364
  }
355
365
  }
356
366
 
367
+ /**
368
+ * A build target names an entry file the app does not have. `x build` refuses before it spawns the
369
+ * builder: `bun build`'s own "module not found" says nothing about which file an Ultimate app is
370
+ * supposed to own, and `docker build`'s says nothing about which target wanted it.
371
+ */
372
+ export class BuildEntryMissingError extends UltimateError {
373
+ constructor(input: { target: string; entry: string }) {
374
+ super({
375
+ code: 'X_BUILD_ENTRY_MISSING',
376
+ cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
377
+ fix: `x new <name> writes ${input.entry} — copy it from a fresh scaffold into this app`,
378
+ docs: docsFor('X_BUILD_ENTRY_MISSING'),
379
+ });
380
+ }
381
+ }
382
+
383
+ /**
384
+ * `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
385
+ * otherwise start, serve nothing and report healthy — the one failure a rolling deploy cannot see.
386
+ */
387
+ export class RoleUnknownError extends UltimateError {
388
+ constructor(input: { role: string; known: readonly string[] }) {
389
+ super({
390
+ code: 'X_ROLE_UNKNOWN',
391
+ cause: `ROLE="${input.role}" is not a role (known: ${input.known.join(', ')})`,
392
+ fix: `docker run -e ROLE=web <image> # one of: ${input.known.join(', ')}`,
393
+ docs: docsFor('X_ROLE_UNKNOWN'),
394
+ });
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Every PaaS injects `PORT` and expects the process to bind exactly it. Defaulting past a value
400
+ * that will not parse is how a deploy comes up on 3000, fails the platform's health probe, and
401
+ * reports nothing an operator can act on.
402
+ */
403
+ export class PortInvalidError extends UltimateError {
404
+ constructor(input: { value: string }) {
405
+ super({
406
+ code: 'X_PORT_INVALID',
407
+ cause: `PORT="${input.value}" is not a TCP port number between 0 and 65535`,
408
+ fix: 'docker run -e PORT=3000 <image>',
409
+ docs: docsFor('X_PORT_INVALID'),
410
+ });
411
+ }
412
+ }
413
+
357
414
  /** An interface-complete command path whose remote/native half is not written yet. */
358
415
  export class CliNotImplementedError extends UltimateError {
359
416
  constructor(input: { feature: string; fix: string }) {
package/src/index.ts CHANGED
@@ -24,7 +24,14 @@ export { planBoundaryCuts } from './boundary-cuts';
24
24
  export type { BuildStats, RouteStats } from './budgets';
25
25
  export { BUILD_STATS_FILE, checkBudgets, readBuildStats } from './budgets';
26
26
  export type { BuildTarget } from './cmd-build';
27
- export { argsFor, BUILD_TARGETS, buildCommand, readTarget } from './cmd-build';
27
+ export {
28
+ argsFor,
29
+ BUILD_ENTRY,
30
+ BUILD_TARGETS,
31
+ buildCommand,
32
+ readTarget,
33
+ requireEntry,
34
+ } from './cmd-build';
28
35
  export { branchDatabaseName, branchSql, dbCommand, previewUrl } from './cmd-db';
29
36
  export type { DeployPlan } from './cmd-deploy';
30
37
  export { deployCommand, planDeploy } from './cmd-deploy';
@@ -64,9 +71,9 @@ export { devHooks } from './dev-hooks';
64
71
  export type { DevDbClient, RunningQueue } from './dev-queue';
65
72
  export { startQueue } from './dev-queue';
66
73
  export type { DevRenderOptions, DevRouteData } from './dev-render';
67
- export { appRoutes } from './dev-render';
68
- export type { RunningRoles, StartRolesOptions } from './dev-roles';
69
- export { DEV_ROLES, selectRoles, startRoles } from './dev-roles';
74
+ export { appRoutes, routeDocument } from './dev-render';
75
+ export type { RunningRoles, StartRolesOptions, WebBinding } from './dev-roles';
76
+ export { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
70
77
  export type { RunningServices } from './dev-runtime';
71
78
  export { startServices } from './dev-runtime';
72
79
  export type { DevServices, ServiceBinding } from './dev-services';
@@ -98,6 +105,7 @@ export {
98
105
  export type { CliErrorCode } from './errors';
99
106
  export {
100
107
  BadFlagError,
108
+ BuildEntryMissingError,
101
109
  BunVersionError,
102
110
  CatalogExistsError,
103
111
  CLI_ERROR_CODES,
@@ -109,6 +117,8 @@ export {
109
117
  JobUnknownError,
110
118
  NoTestFilesError,
111
119
  NotInAppError,
120
+ PortInvalidError,
121
+ RoleUnknownError,
112
122
  UnknownCommandError,
113
123
  VerifyFailedError,
114
124
  } from './errors';
@@ -122,6 +132,7 @@ export { renderJobTable } from './jobs-table';
122
132
  export type { CliMcpServer, DevHostInput } from './mcp-host';
123
133
  export { createDevMcpServer, DEV_TOOL_SCOPES, localCaller } from './mcp-host';
124
134
  export { messageKeys, msg } from './messages';
135
+ export { MIGRATIONS_DIR, migrationName, parseMigrationSql, readMigrations } from './migrations';
125
136
  export type { CommandResult, Finding, JsonValue, StepResult } from './output';
126
137
  export {
127
138
  exitCodeFor,
@@ -135,7 +146,19 @@ export {
135
146
  } from './output';
136
147
  export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
137
148
  export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
149
+ export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
150
+ export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
138
151
  export { CLI_VERSION, COMMANDS, commandFor, SPECS } from './registry';
152
+ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
153
+ export {
154
+ CONTAINER_BINDING,
155
+ DEFAULT_PORT,
156
+ portFromEnv,
157
+ roleFromEnv,
158
+ runMigrations,
159
+ runRole,
160
+ serveApp,
161
+ } from './serve';
139
162
  export {
140
163
  eachSourceFile,
141
164
  isGenerated,
package/src/mcp-errors.ts CHANGED
@@ -47,7 +47,13 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
47
47
  X_MANIFEST_STALE: 'x manifest --json',
48
48
  X_BUDGET_UNMEASURED: 'x build --json && x verify --json',
49
49
  X_BUILD_FAILED: 'x build --json # the finding names the failing step',
50
+ X_BUILD_ENTRY_MISSING:
51
+ 'x new <name> --dry-run --json # the file list names every entry a build needs',
50
52
  X_DEPLOY_FAILED: 'x deploy --json # the finding carries the command to re-run directly',
53
+ // The container's own environment, so the answer is the run that sets it — never an `x` command,
54
+ // which is not what is running when a `ROLE=wroker` pod refuses to boot.
55
+ X_ROLE_UNKNOWN: 'docker run -e ROLE=web <image>',
56
+ X_PORT_INVALID: 'docker run -e PORT=3000 <image>',
51
57
  X_GENERATE_CONFLICT: 'x g <kind> <name> --force --json',
52
58
  X_PORT_IN_USE: 'x dev --port 3001 --json',
53
59
  // Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
@@ -0,0 +1,45 @@
1
+ // The app's SQL migrations, read off disk into `@ultimat3/db`'s own `Migration` shape. One reader,
2
+ // because the release phase and the developer must apply the identical list through the identical
3
+ // ledger — a deploy that migrated by some other route is a schema nobody can reconstruct.
4
+
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import type { Migration } from '@ultimat3/db';
8
+
9
+ /** Where `x new` writes them and where `x db gen` adds to. App-root-relative, POSIX. */
10
+ export const MIGRATIONS_DIR = 'packages/db/migrations';
11
+
12
+ /**
13
+ * `-- down` alone on a line splits a migration file. Anchored to the whole line so a comment that
14
+ * merely mentions the word — `-- down migrations are required` — is not mistaken for the marker.
15
+ */
16
+ const DOWN_MARKER = /^[ \t]*--[ \t]*down[ \t]*$/im;
17
+
18
+ /** `0000_initial` → the ledger id; `initial` → the name a conflict message prints. */
19
+ export const migrationName = (id: string): string => id.replace(/^\d+_/, '');
20
+
21
+ export function parseMigrationSql(id: string, sql: string): Migration {
22
+ const marker = DOWN_MARKER.exec(sql);
23
+ const up = (marker === null ? sql : sql.slice(0, marker.index)).trim();
24
+ const down = marker === null ? '' : sql.slice(marker.index + marker[0].length).trim();
25
+ return { id, name: migrationName(id), up, down };
26
+ }
27
+
28
+ /**
29
+ * Sorted by id, because that is the apply order and `pendingMigrations` re-sorts on the same key.
30
+ * A missing directory is an empty list rather than a throw: an app can legitimately declare no
31
+ * entity yet, and the count is reported so "nothing was applied" is never silent.
32
+ */
33
+ export async function readMigrations(root: string): Promise<readonly Migration[]> {
34
+ const dir = join(root, MIGRATIONS_DIR);
35
+ if (!existsSync(dir)) return [];
36
+ const files: string[] = [];
37
+ for await (const file of new Bun.Glob('*.sql').scan({ cwd: dir })) files.push(file);
38
+ files.sort();
39
+ const migrations: Migration[] = [];
40
+ for (const file of files) {
41
+ const text = await Bun.file(join(dir, file)).text();
42
+ migrations.push(parseMigrationSql(file.replace(/\.sql$/, ''), text));
43
+ }
44
+ return migrations;
45
+ }
@@ -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,176 @@
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 { readMigrations } from './migrations';
26
+
27
+ export const DEFAULT_PORT = 3000;
28
+
29
+ /** Every interface. A container bound to loopback is unreachable through its own port mapping. */
30
+ export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
31
+
32
+ /**
33
+ * `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
34
+ * to `web` would start a process that serves nothing the operator asked for and reports healthy.
35
+ */
36
+ export function roleFromEnv(env: Env): Role {
37
+ const raw = env['ROLE'] ?? 'web';
38
+ if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
39
+ return raw;
40
+ }
41
+
42
+ /**
43
+ * Every PaaS injects `PORT` and routes traffic to exactly it. `Number.parseInt` would read `80abc`
44
+ * as 80, so the whole string has to be a port — a partially-parsed port is a deploy that binds
45
+ * somewhere nobody asked for.
46
+ */
47
+ export function portFromEnv(env: Env): number {
48
+ const raw = env['PORT'];
49
+ if (raw === undefined || raw.trim().length === 0) return DEFAULT_PORT;
50
+ const port = Number(raw.trim());
51
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
52
+ throw new PortInvalidError({ value: raw });
53
+ return port;
54
+ }
55
+
56
+ export interface ServeOptions {
57
+ readonly root: string;
58
+ readonly env: Env;
59
+ /** Overrides `ROLE`; `runRole` reads the environment when this is absent. */
60
+ readonly role?: Role;
61
+ /** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
62
+ readonly port?: number;
63
+ }
64
+
65
+ export interface ServedApp {
66
+ readonly kind: 'served';
67
+ readonly role: Role;
68
+ /** `http://…` for the web role; null for the roles that open no HTTP socket. */
69
+ readonly url: string | null;
70
+ readonly buildId: string;
71
+ readonly running: RunningRoles;
72
+ readonly runtime: RunningServices;
73
+ stop(): Promise<void>;
74
+ }
75
+
76
+ export interface MigratedApp {
77
+ readonly kind: 'migrated';
78
+ readonly role: 'migrate';
79
+ readonly report: MigrationReport;
80
+ }
81
+
82
+ export type StartedApp = ServedApp | MigratedApp;
83
+
84
+ /**
85
+ * The release phase, as a role. `migrate` is not a server: it applies the app's own migrations
86
+ * through `@ultimat3/db`'s ledger — advisory lock, per-migration checksum, app-version fence — and
87
+ * exits, so a platform that runs one container to completion before the rest start (Heroku's
88
+ * release phase, a compose `service_completed_successfully`, a Kubernetes Job) has exactly one
89
+ * thing to run and no framework-specific flag to learn.
90
+ *
91
+ * It boots the queue, not the whole runtime: this role touches the database and nothing else, and
92
+ * `startQueue` is what installs `db()` for `migrate()` to find.
93
+ */
94
+ export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
95
+ const queue = await startQueue(resolveServices(options.root, options.env));
96
+ try {
97
+ const migrations = await readMigrations(options.root);
98
+ const report = await migrate({
99
+ migrations,
100
+ ...(options.env['APP_VERSION'] === undefined
101
+ ? {}
102
+ : { appVersion: options.env['APP_VERSION'] }),
103
+ });
104
+ logger.info('ultimate migrate applied', {
105
+ applied: report.applied.length,
106
+ available: migrations.length,
107
+ appVersion: report.appVersion,
108
+ });
109
+ return { kind: 'migrated', role: 'migrate', report };
110
+ } finally {
111
+ await queue.stop();
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Boot order is `x dev`'s, for the reason `x dev` gives: services, then the app's own modules
117
+ * (importing them IS the registration), then the role that serves what they registered. The route
118
+ * table is the same three contributions minus the dashboard — a `/_x` in production would expose
119
+ * the app's policy matrix, its outbox and its spans to the internet.
120
+ */
121
+ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
122
+ const role = options.role ?? roleFromEnv(options.env);
123
+ const runtime = await startServices(resolveServices(options.root, options.env), options.env);
124
+ // Importing the app's modules IS the registration: every route, action and job below is
125
+ // whatever this call put in the registries.
126
+ await loadApp(options.root);
127
+ // The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
128
+ // answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only
129
+ // when unstamped, because a stamped image already paid for it at build time and a replica's boot
130
+ // should not repeat it — the load above is the part every boot needs either way.
131
+ const stamped = options.env['BUILD_ID'];
132
+ const buildId =
133
+ stamped !== undefined && stamped.length > 0
134
+ ? stamped
135
+ : (await appManifest(options.root)).manifest.buildId;
136
+ const routes: readonly Route[] = [
137
+ ...listActions().map(toRoute),
138
+ ...assetRoutes({ root: options.root, storage: runtime.storage }),
139
+ ...appRoutes({ buildId }),
140
+ ];
141
+ const running = await startRoles({
142
+ roles: [role],
143
+ port: options.port ?? portFromEnv(options.env),
144
+ buildId,
145
+ runtime,
146
+ routes,
147
+ env: options.env,
148
+ http: CONTAINER_BINDING,
149
+ });
150
+ return {
151
+ kind: 'served',
152
+ role,
153
+ url: running.url,
154
+ buildId,
155
+ running,
156
+ runtime,
157
+ async stop() {
158
+ await running.stop();
159
+ await runtime.stop();
160
+ },
161
+ };
162
+ }
163
+
164
+ /**
165
+ * What `apps/web/server.ts` calls. Returns for `migrate` — the process is meant to exit — and
166
+ * holds for every other role until core's drain completes, so SIGTERM from a rolling restart takes
167
+ * the three-phase path (stop accepting, finish in-flight, close) instead of killing a query.
168
+ */
169
+ export async function runRole(options: ServeOptions): Promise<StartedApp> {
170
+ const role = options.role ?? roleFromEnv(options.env);
171
+ if (role === 'migrate') return runMigrations({ ...options, role });
172
+ const app = await serveApp({ ...options, role });
173
+ logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
174
+ await holdUntilShutdown('serve', () => app.stop())();
175
+ return app;
176
+ }
@@ -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
 
@@ -1,14 +1,19 @@
1
1
  // The config half of what `x new` writes: the one config file, the tooling configs and the
2
2
  // workspace packages. Committed defaults only — a fresh clone boots with `x dev` and no env
3
- // scavenger hunt. The docs, shims and container files live in scaffold-docs.ts.
3
+ // scavenger hunt. The docs and shims live in scaffold-docs.ts, the container files in
4
+ // scaffold-container.ts.
4
5
 
5
6
  import type { GeneratedFile, NameSet } from './naming';
6
7
  import { docsFiles } from './scaffold-docs';
7
8
  import { i18nFiles } from './scaffold-i18n';
8
9
  import { packageShapeFiles } from './scaffold-package-shape';
9
10
 
11
+ // `version` is not decoration: the manifest's app version IS the contract's compatibility gate,
12
+ // and the manifest never fabricates one — so an app scaffolded without it failed `x manifest`,
13
+ // the `manifest` verify step and every production boot with X_APP_PACKAGE_INVALID.
10
14
  const rootPackage = (app: NameSet, version: string): string => `{
11
15
  "name": "${app.kebab}",
16
+ "version": "0.1.0",
12
17
  "private": true,
13
18
  "type": "module",
14
19
  "workspaces": [
@@ -64,6 +69,8 @@ const rootTsconfig = (app: NameSet): string => `{
64
69
  "lib": ["ES2023", "DOM", "DOM.Iterable"],
65
70
  "types": ["bun"],
66
71
  "paths": {
72
+ "@${app.kebab}/web/*": ["./apps/web/*"],
73
+ "@${app.kebab}/admin/*": ["./apps/admin/*"],
67
74
  "@${app.kebab}/*": ["./packages/*/src"]
68
75
  },
69
76
  "strict": true,
@@ -105,10 +112,13 @@ export const config = defineConfig({
105
112
  });
106
113
  `;
107
114
 
115
+ // `biome.json` is strict JSON — Biome's own parser rejects a `//` comment in it, which made every
116
+ // scaffolded app fail its first `x verify` on the config rather than on the code. The note that
117
+ // used to be a comment lives here, where it is read by the person who would have changed the line:
118
+ // x.manifest.json and openapi.json are emitted byte-for-byte by `x manifest`, so a formatter
119
+ // rewriting them puts `x manifest` and `x verify` in a loop neither can win.
108
120
  const biome = (): string => `{
109
121
  "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
110
- // x.manifest.json and openapi.json are emitted byte-for-byte by \`x manifest\`; a formatter
111
- // rewriting them puts \`x manifest\` and \`x verify\` in a loop neither can win.
112
122
  "files": { "includes": ["**", "!x.manifest.json", "!openapi.json"] },
113
123
  "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
114
124
  "linter": {