@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.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.2.0",
38
+ "@ultimat3/admin": "1.2.0",
39
+ "@ultimat3/ai": "1.2.0",
40
+ "@ultimat3/cache": "1.2.0",
41
+ "@ultimat3/core": "1.2.0",
42
+ "@ultimat3/db": "1.2.0",
43
+ "@ultimat3/entity": "1.2.0",
44
+ "@ultimat3/http": "1.2.0",
45
+ "@ultimat3/i18n": "1.2.0",
46
+ "@ultimat3/jobs": "1.2.0",
47
+ "@ultimat3/mail": "1.2.0",
48
+ "@ultimat3/manifest": "1.2.0",
49
+ "@ultimat3/mcp": "1.2.0",
50
+ "@ultimat3/policy": "1.2.0",
51
+ "@ultimat3/pwa": "1.2.0",
52
+ "@ultimat3/query": "1.2.0",
53
+ "@ultimat3/realtime": "1.2.0",
54
+ "@ultimat3/render": "1.2.0",
55
+ "@ultimat3/seo": "1.2.0",
56
+ "@ultimat3/storage": "1.2.0",
57
+ "@ultimat3/testing": "1.2.0",
58
+ "@ultimat3/time": "1.2.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/cmd-dev.ts CHANGED
@@ -8,7 +8,7 @@ import { watch } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { listActions, toRoute } from '@ultimat3/action';
10
10
  import type { Role } from '@ultimat3/core';
11
- import { configureTelemetry, noopExporter } from '@ultimat3/core';
11
+ import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
12
12
  import type { Route } from '@ultimat3/http';
13
13
  import type { Manifest } from '@ultimat3/manifest';
14
14
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
@@ -249,6 +249,9 @@ export const devCommand: CliCommand = {
249
249
  url: server.url,
250
250
  roles: [...server.roles],
251
251
  sync: server.running.syncUrl,
252
+ // The scrape target, on its own port for every role: what an operator points a Prometheus
253
+ // at, and the one url here that must NOT be behind the ingress the app's own url is.
254
+ metrics: `${server.running.metricsUrl}${METRICS_PATH}`,
252
255
  stateDir: server.services.stateDir,
253
256
  db: server.services.db.url,
254
257
  events: server.services.events.url,
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
@@ -29,6 +29,7 @@ import { startReplicator } from './dev-replicator';
29
29
  import type { RunningServices } from './dev-runtime';
30
30
  import type { Env } from './dev-services';
31
31
  import { BadFlagError } from './errors';
32
+ import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
32
33
 
33
34
  /** The roles `x dev` starts when `--role` names none, in boot order. */
34
35
  export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
@@ -49,14 +50,38 @@ export interface StartRolesOptions {
49
50
  readonly routes: readonly Route[];
50
51
  /** The process environment, for the roles that resolve a driver from it. */
51
52
  readonly env: Env;
53
+ /**
54
+ * How the web role binds and what it admits about itself. `x dev` keeps the default —
55
+ * loopback, `dev: true`, so a laptop on a café network is not serving the app to the café. A
56
+ * container passes `{ dev: false, hostname: '0.0.0.0' }`: a process bound to `localhost` inside
57
+ * a container is unreachable from the port mapping, the load balancer and every PaaS health
58
+ * probe, which is the same failure in four costumes.
59
+ */
60
+ readonly http?: WebBinding;
61
+ /**
62
+ * Where the scrape listener binds. Defaults to `DEFAULT_METRICS_PORT`, except when `port` is 0
63
+ * — a caller asking the kernel for an ephemeral HTTP port is a test, and a test that grabbed
64
+ * 9090 would fail the next one to run beside it.
65
+ */
66
+ readonly metricsPort?: number;
52
67
  }
53
68
 
69
+ export interface WebBinding {
70
+ readonly dev: boolean;
71
+ readonly hostname: string;
72
+ }
73
+
74
+ /** Loopback and dev-mode. What `x dev` means, and what a container must override. */
75
+ export const DEV_BINDING: WebBinding = { dev: true, hostname: 'localhost' };
76
+
54
77
  export interface RunningRoles {
55
78
  readonly roles: readonly Role[];
56
79
  /** `http://…` once the web role is up; null when it was not selected. */
57
80
  readonly url: string | null;
58
81
  /** Where the sync role accepts websockets; null when it was not selected. */
59
82
  readonly syncUrl: string | null;
83
+ /** `http://…` — the scrape base. Never null: every role publishes a signal worth scaling on. */
84
+ readonly metricsUrl: string;
60
85
  readonly server: ServerHandle | null;
61
86
  readonly worker: Worker | null;
62
87
  readonly scheduler: Scheduler | null;
@@ -99,15 +124,16 @@ export function selectRoles(flag: string | undefined): readonly Role[] {
99
124
  }
100
125
 
101
126
  function startWeb(options: StartRolesOptions): ServerHandle {
127
+ const binding = options.http ?? DEV_BINDING;
102
128
  return createServer({
103
129
  routes: options.routes,
104
130
  role: 'web',
105
131
  hooks: devHooks(),
106
132
  config: defineHttpConfig({
107
133
  port: options.port,
108
- dev: true,
134
+ dev: binding.dev,
109
135
  buildId: options.buildId,
110
- hostname: 'localhost',
136
+ hostname: binding.hostname,
111
137
  }),
112
138
  }).start();
113
139
  }
@@ -186,6 +212,15 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
186
212
  // Without this a failed `sync` leaves the web server bound and unreachable by any caller.
187
213
  const started: (() => Promise<void>)[] = [];
188
214
  try {
215
+ // First, and for every role rather than only the two that open an HTTP socket: `worker` and
216
+ // `sync` are precisely the roles whose HPAs read a series the process itself has to publish,
217
+ // and a `worker` container with no listener is an HPA pinned at `<unknown>` forever.
218
+ const metrics = startMetricsEndpoint({
219
+ port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
220
+ ...(options.http === undefined ? {} : { hostname: options.http.hostname }),
221
+ });
222
+ started.push(async () => metrics.stop());
223
+
189
224
  const server = selected.includes('web') ? startWeb(options) : null;
190
225
  if (server !== null) started.push(() => server.stop());
191
226
 
@@ -223,6 +258,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
223
258
  roles: selected,
224
259
  url: server === null ? null : server.url(),
225
260
  syncUrl: sync?.url ?? null,
261
+ metricsUrl: metrics.url,
226
262
  server,
227
263
  worker,
228
264
  scheduler,
@@ -234,6 +270,8 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
234
270
  await worker?.stop('x dev stopped');
235
271
  await sync?.stop();
236
272
  await server?.stop();
273
+ // Last: a scrape taken while the roles above drain is the one that explains the drain.
274
+ metrics.stop();
237
275
  },
238
276
  };
239
277
  } catch (error) {
@@ -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,55 @@ 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
+ /** `name` so the scrape port reports itself; the code stays one, because the fault is one. */
405
+ constructor(input: { value: string; name?: string }) {
406
+ const name = input.name ?? 'PORT';
407
+ super({
408
+ code: 'X_PORT_INVALID',
409
+ cause: `${name}="${input.value}" is not a TCP port number between 0 and 65535`,
410
+ fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090} <image>`,
411
+ docs: docsFor('X_PORT_INVALID'),
412
+ });
413
+ }
414
+ }
415
+
357
416
  /** An interface-complete command path whose remote/native half is not written yet. */
358
417
  export class CliNotImplementedError extends UltimateError {
359
418
  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,9 @@ 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 type { MetricsEndpoint, MetricsEndpointOptions } from './metrics-endpoint';
136
+ export { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
137
+ export { MIGRATIONS_DIR, migrationName, parseMigrationSql, readMigrations } from './migrations';
125
138
  export type { CommandResult, Finding, JsonValue, StepResult } from './output';
126
139
  export {
127
140
  exitCodeFor,
@@ -135,7 +148,20 @@ export {
135
148
  } from './output';
136
149
  export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
137
150
  export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
151
+ export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
152
+ export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
138
153
  export { CLI_VERSION, COMMANDS, commandFor, SPECS } from './registry';
154
+ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
155
+ export {
156
+ CONTAINER_BINDING,
157
+ DEFAULT_PORT,
158
+ metricsPortFromEnv,
159
+ portFromEnv,
160
+ roleFromEnv,
161
+ runMigrations,
162
+ runRole,
163
+ serveApp,
164
+ } from './serve';
139
165
  export {
140
166
  eachSourceFile,
141
167
  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,72 @@
1
+ // Single responsibility: the scrape listener every role opens. `@ultimat3/core` declares the
2
+ // series and renders the body; this is the one place a process answers `METRICS_PATH` with it, so
3
+ // `docker/helm`'s HPAs read a number instead of `<unknown>`.
4
+
5
+ import {
6
+ logger,
7
+ METRICS_CONTENT_TYPE,
8
+ METRICS_PATH,
9
+ markListening,
10
+ metricsText,
11
+ } from '@ultimat3/core';
12
+
13
+ /**
14
+ * A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
15
+ * `docker/helm/templates/ingress.yaml` routes `path: /` `Prefix` to the web Service, so a
16
+ * `/metrics` mounted beside `/healthz` on port 3000 is `/metrics` on the internet — route
17
+ * patterns, request volumes and error rates, published. Nothing in the chart fronts this port:
18
+ * `service.yaml` only publishes `http`, so the endpoint is cluster-internal by construction
19
+ * rather than by an ingress exclusion somebody has to remember to write.
20
+ *
21
+ * It is also the only thing `worker`, `scheduler` and `replicator` could ever be scraped on —
22
+ * they open no HTTP socket at all, and `queue_depth` is exactly the signal one of them owns.
23
+ * 9090 is Prometheus's own convention, so a scrape config that assumes it needs no edit.
24
+ */
25
+ export const DEFAULT_METRICS_PORT = 9090;
26
+
27
+ export interface MetricsEndpointOptions {
28
+ /** 0 asks the kernel for an ephemeral port, which is what a test wants. */
29
+ readonly port?: number;
30
+ /** A container must bind every interface; a laptop must not. Same decision as the web role. */
31
+ readonly hostname?: string;
32
+ }
33
+
34
+ export interface MetricsEndpoint {
35
+ /** `http://host:port` — the base the scrape target appends `METRICS_PATH` to. */
36
+ readonly url: string;
37
+ stop(): void;
38
+ }
39
+
40
+ /**
41
+ * Answers outside the request pipeline, exactly as `/healthz` and `/readyz` do in
42
+ * `@ultimat3/http`'s `server.ts`: no auth, no rate limit, no locale negotiation. A saturated or
43
+ * draining process must still be able to say how saturated it is — an autoscaler that loses its
44
+ * signal at the moment of load is worse than no autoscaler.
45
+ */
46
+ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): MetricsEndpoint {
47
+ const server = Bun.serve({
48
+ port: options.port ?? DEFAULT_METRICS_PORT,
49
+ hostname: options.hostname ?? 'localhost',
50
+ fetch(request: Request): Response {
51
+ if (new URL(request.url).pathname !== METRICS_PATH) {
52
+ return new Response('not found', { status: 404 });
53
+ }
54
+ // `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot steal
55
+ // each other's samples — but a cache would hand the second one a stale window.
56
+ return new Response(metricsText(), {
57
+ headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
58
+ });
59
+ },
60
+ });
61
+ // Same rule as every other socket the framework opens: announce it, so a request back to it is
62
+ // recognisably this process calling itself rather than egress the test seal must refuse.
63
+ const stopListening = markListening(server.url.origin);
64
+ logger.info('ultimate metrics listening', { url: `${server.url.origin}${METRICS_PATH}` });
65
+ return {
66
+ url: server.url.origin,
67
+ stop(): void {
68
+ server.stop(true);
69
+ stopListening();
70
+ },
71
+ };
72
+ }
@@ -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
+ }