@ultimat3/cli 1.1.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.1.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.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"
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/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-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'];
@@ -57,6 +58,12 @@ export interface StartRolesOptions {
57
58
  * probe, which is the same failure in four costumes.
58
59
  */
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;
60
67
  }
61
68
 
62
69
  export interface WebBinding {
@@ -73,6 +80,8 @@ export interface RunningRoles {
73
80
  readonly url: string | null;
74
81
  /** Where the sync role accepts websockets; null when it was not selected. */
75
82
  readonly syncUrl: string | null;
83
+ /** `http://…` — the scrape base. Never null: every role publishes a signal worth scaling on. */
84
+ readonly metricsUrl: string;
76
85
  readonly server: ServerHandle | null;
77
86
  readonly worker: Worker | null;
78
87
  readonly scheduler: Scheduler | null;
@@ -203,6 +212,15 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
203
212
  // Without this a failed `sync` leaves the web server bound and unreachable by any caller.
204
213
  const started: (() => Promise<void>)[] = [];
205
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
+
206
224
  const server = selected.includes('web') ? startWeb(options) : null;
207
225
  if (server !== null) started.push(() => server.stop());
208
226
 
@@ -240,6 +258,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
240
258
  roles: selected,
241
259
  url: server === null ? null : server.url(),
242
260
  syncUrl: sync?.url ?? null,
261
+ metricsUrl: metrics.url,
243
262
  server,
244
263
  worker,
245
264
  scheduler,
@@ -251,6 +270,8 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
251
270
  await worker?.stop('x dev stopped');
252
271
  await sync?.stop();
253
272
  await server?.stop();
273
+ // Last: a scrape taken while the roles above drain is the one that explains the drain.
274
+ metrics.stop();
254
275
  },
255
276
  };
256
277
  } catch (error) {
package/src/errors.ts CHANGED
@@ -401,11 +401,13 @@ export class RoleUnknownError extends UltimateError {
401
401
  * reports nothing an operator can act on.
402
402
  */
403
403
  export class PortInvalidError extends UltimateError {
404
- constructor(input: { value: string }) {
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';
405
407
  super({
406
408
  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
+ 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>`,
409
411
  docs: docsFor('X_PORT_INVALID'),
410
412
  });
411
413
  }
package/src/index.ts CHANGED
@@ -132,6 +132,8 @@ export { renderJobTable } from './jobs-table';
132
132
  export type { CliMcpServer, DevHostInput } from './mcp-host';
133
133
  export { createDevMcpServer, DEV_TOOL_SCOPES, localCaller } from './mcp-host';
134
134
  export { messageKeys, msg } from './messages';
135
+ export type { MetricsEndpoint, MetricsEndpointOptions } from './metrics-endpoint';
136
+ export { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
135
137
  export { MIGRATIONS_DIR, migrationName, parseMigrationSql, readMigrations } from './migrations';
136
138
  export type { CommandResult, Finding, JsonValue, StepResult } from './output';
137
139
  export {
@@ -153,6 +155,7 @@ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
153
155
  export {
154
156
  CONTAINER_BINDING,
155
157
  DEFAULT_PORT,
158
+ metricsPortFromEnv,
156
159
  portFromEnv,
157
160
  roleFromEnv,
158
161
  runMigrations,
@@ -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
+ }
package/src/serve.ts CHANGED
@@ -22,6 +22,7 @@ import type { Env } from './dev-services';
22
22
  import { resolveServices } from './dev-services';
23
23
  import { PortInvalidError, RoleUnknownError } from './errors';
24
24
  import { holdUntilShutdown } from './hold';
25
+ import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
25
26
  import { readMigrations } from './migrations';
26
27
 
27
28
  export const DEFAULT_PORT = 3000;
@@ -40,19 +41,32 @@ export function roleFromEnv(env: Env): Role {
40
41
  }
41
42
 
42
43
  /**
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.
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
46
  */
47
- export function portFromEnv(env: Env): number {
48
- const raw = env['PORT'];
49
- if (raw === undefined || raw.trim().length === 0) return DEFAULT_PORT;
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
50
  const port = Number(raw.trim());
51
51
  if (!Number.isInteger(port) || port < 0 || port > 65_535)
52
- throw new PortInvalidError({ value: raw });
52
+ throw new PortInvalidError({ value: raw, name });
53
53
  return port;
54
54
  }
55
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
+
56
70
  export interface ServeOptions {
57
71
  readonly root: string;
58
72
  readonly env: Env;
@@ -60,6 +74,8 @@ export interface ServeOptions {
60
74
  readonly role?: Role;
61
75
  /** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
62
76
  readonly port?: number;
77
+ /** Overrides `METRICS_PORT`, on the same terms. */
78
+ readonly metricsPort?: number;
63
79
  }
64
80
 
65
81
  export interface ServedApp {
@@ -138,9 +154,17 @@ export async function serveApp(options: ServeOptions): Promise<ServedApp> {
138
154
  ...assetRoutes({ root: options.root, storage: runtime.storage }),
139
155
  ...appRoutes({ buildId }),
140
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));
141
164
  const running = await startRoles({
142
165
  roles: [role],
143
- port: options.port ?? portFromEnv(options.env),
166
+ port,
167
+ metricsPort,
144
168
  buildId,
145
169
  runtime,
146
170
  routes,