@ultimat3/cli 27.10.1 → 27.10.3

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": "27.10.1",
3
+ "version": "27.10.3",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "27.10.1",
43
- "@ultimat3/admin": "27.10.1",
44
- "@ultimat3/ai": "27.10.1",
45
- "@ultimat3/auth": "27.10.1",
46
- "@ultimat3/cache": "27.10.1",
47
- "@ultimat3/core": "27.10.1",
48
- "@ultimat3/db": "27.10.1",
49
- "@ultimat3/entity": "27.10.1",
50
- "@ultimat3/flags": "27.10.1",
51
- "@ultimat3/http": "27.10.1",
52
- "@ultimat3/i18n": "27.10.1",
53
- "@ultimat3/jobs": "27.10.1",
54
- "@ultimat3/mail": "27.10.1",
55
- "@ultimat3/manifest": "27.10.1",
56
- "@ultimat3/mcp": "27.10.1",
57
- "@ultimat3/money": "27.10.1",
58
- "@ultimat3/notify": "27.10.1",
59
- "@ultimat3/policy": "27.10.1",
60
- "@ultimat3/pwa": "27.10.1",
61
- "@ultimat3/query": "27.10.1",
62
- "@ultimat3/realtime": "27.10.1",
63
- "@ultimat3/render": "27.10.1",
64
- "@ultimat3/schema": "27.10.1",
65
- "@ultimat3/seo": "27.10.1",
66
- "@ultimat3/storage": "27.10.1",
67
- "@ultimat3/testing": "27.10.1",
68
- "@ultimat3/time": "27.10.1",
42
+ "@ultimat3/action": "27.10.3",
43
+ "@ultimat3/admin": "27.10.3",
44
+ "@ultimat3/ai": "27.10.3",
45
+ "@ultimat3/auth": "27.10.3",
46
+ "@ultimat3/cache": "27.10.3",
47
+ "@ultimat3/core": "27.10.3",
48
+ "@ultimat3/db": "27.10.3",
49
+ "@ultimat3/entity": "27.10.3",
50
+ "@ultimat3/flags": "27.10.3",
51
+ "@ultimat3/http": "27.10.3",
52
+ "@ultimat3/i18n": "27.10.3",
53
+ "@ultimat3/jobs": "27.10.3",
54
+ "@ultimat3/mail": "27.10.3",
55
+ "@ultimat3/manifest": "27.10.3",
56
+ "@ultimat3/mcp": "27.10.3",
57
+ "@ultimat3/money": "27.10.3",
58
+ "@ultimat3/notify": "27.10.3",
59
+ "@ultimat3/policy": "27.10.3",
60
+ "@ultimat3/pwa": "27.10.3",
61
+ "@ultimat3/query": "27.10.3",
62
+ "@ultimat3/realtime": "27.10.3",
63
+ "@ultimat3/render": "27.10.3",
64
+ "@ultimat3/schema": "27.10.3",
65
+ "@ultimat3/seo": "27.10.3",
66
+ "@ultimat3/storage": "27.10.3",
67
+ "@ultimat3/testing": "27.10.3",
68
+ "@ultimat3/time": "27.10.3",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -3,6 +3,7 @@
3
3
  // probed `NaN`, which `portFree` reports as free — a check that CANNOT FAIL — `x dev --port abc`
4
4
  // handed `NaN` to `Bun.serve` and bound an arbitrary port, and `x test --workers 4abc` ran four.
5
5
 
6
+ import { UltimateError } from '@ultimat3/core';
6
7
  import { BadFlagError } from './errors';
7
8
  import type { ParsedArgs } from './parse';
8
9
  import { flagString } from './parse';
@@ -80,3 +81,37 @@ export const neighbouringPort = (port: number): number =>
80
81
  */
81
82
  export const portPairAfter = (port: number): number =>
82
83
  port + 2 < PORT_RANGE.max ? port + 2 : port - 2;
84
+
85
+ /**
86
+ * Beside its one thrower rather than in `errors.ts`, which is near its 500-line ceiling — the
87
+ * arrangement `db-seed.ts` and `metrics-endpoint.ts` already take. The code is
88
+ * `X_PORT_INVALID`, this package's own: "the port asked for is not one" is what it already means,
89
+ * and a second code for the same fact is the synonym the registry exists to prevent.
90
+ */
91
+ class SyncPortUnavailableError extends UltimateError {
92
+ constructor(input: { port: number }) {
93
+ super({
94
+ code: 'X_PORT_INVALID',
95
+ cause: `the sync role binds PORT + 1, and PORT=${input.port} is the top of the range — it would ask for ${input.port + 1}, which is not a TCP port`,
96
+ fix: `x dev --port ${neighbouringPort(input.port)} # leaves ${PORT_RANGE.max} free for the sync node`,
97
+ meta: { port: input.port },
98
+ });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The port the sync node listens on. `PORT + 1`, and `0` stays `0` — the kernel picks, and adding
104
+ * one to it would pick a specific port instead.
105
+ *
106
+ * REFUSED at the top of the range, never clamped. `PORT_RANGE.max` is 65535 and `portValue`
107
+ * accepts it, so `x dev --port 65535` handed `Bun.serve` 65536 and the bare `RangeError` reached
108
+ * the terminal as `X_CLI_UNEXPECTED` with `fix: x doctor --json`. Clamping to 65534 would be worse
109
+ * than refusing: `PORT + 1` is the rule `docker/docker-compose.prod.yml` publishes `3001:3001`
110
+ * from and `docker/helm` derives `PORT = .port - 1` from, so a node quietly on `PORT - 1` is a
111
+ * socket nothing else in the deployment computes.
112
+ */
113
+ export function syncPortFor(port: number): number {
114
+ if (port === 0) return 0;
115
+ if (port >= PORT_RANGE.max) throw new SyncPortUnavailableError({ port });
116
+ return port + 1;
117
+ }
@@ -3,6 +3,7 @@
3
3
  // `docker/helm`'s HPAs read a number instead of `<unknown>`. Opening it starts the process's own
4
4
  // series (`process_*`): a role that can be scraped reports what it costs.
5
5
 
6
+ import type { Role } from '@ultimat3/core';
6
7
  import {
7
8
  BUILD_ID_HEADER,
8
9
  finiteCount,
@@ -18,7 +19,7 @@ import {
18
19
  stringField,
19
20
  UltimateError,
20
21
  } from '@ultimat3/core';
21
- import { neighbouringPort } from './flag-number';
22
+ import { neighbouringPort, PORT_RANGE, syncPortFor } from './flag-number';
22
23
  import { quoteArg } from './shell-quote';
23
24
 
24
25
  /**
@@ -55,6 +56,60 @@ export class MetricsPortInUseError extends UltimateError {
55
56
  }
56
57
  }
57
58
 
59
+ /**
60
+ * The same code, for a collision the kernel may NOT report: bound to `localhost` where that names
61
+ * both `::1` and `127.0.0.1`, a scrape listener and a role on one number each get an address family
62
+ * and both succeed, and the browser's requests reach `/metrics`'s `not found` (CI, 2026-10-14). So
63
+ * it is refused by number, before either binds. The fix names 9090 unless a role holds it.
64
+ */
65
+ // Which ports this boot's own roles bind, and a scrape port apart from them: one copy, read by
66
+ // both openers of the scrape listener — `startRoles` and `serve.ts`, which opens it before the roles.
67
+
68
+ /** A port one of this process's own roles binds — what a scrape port must never be. */
69
+ export interface RolePort {
70
+ readonly role: string;
71
+ readonly port: number;
72
+ }
73
+
74
+ /**
75
+ * The web role's port, and the sync node's — `syncPortFor`'s answer, the one rule the node binds
76
+ * by (so it refuses the top of the range here exactly as the boot would). Port 0 reserves nothing:
77
+ * the kernel picks, and never a bound port.
78
+ */
79
+ export function rolePorts(port: number, roles: readonly Role[]): readonly RolePort[] {
80
+ if (port === 0) return [];
81
+ return [
82
+ ...(roles.includes('web') ? [{ role: 'web', port }] : []),
83
+ ...(roles.includes('sync') ? [{ role: 'sync', port: syncPortFor(port) }] : []),
84
+ ];
85
+ }
86
+
87
+ /**
88
+ * A port a `fix:` may name: `preferred` unless a role holds it, else the first free one above every
89
+ * reservation, else the first below — a handful of reservations can never fill the range.
90
+ */
91
+ export function portApartFrom(reserved: readonly RolePort[], preferred: number): number {
92
+ const held = new Set(reserved.map((one) => one.port));
93
+ if (!held.has(preferred)) return preferred;
94
+ for (let port = Math.max(...held) + 1; port <= PORT_RANGE.max; port += 1) {
95
+ if (!held.has(port)) return port;
96
+ }
97
+ let port = Math.min(...held) - 1;
98
+ while (held.has(port)) port -= 1;
99
+ return port;
100
+ }
101
+
102
+ export class MetricsPortSharedError extends UltimateError {
103
+ constructor(input: { port: number; with: RolePort; reserved: readonly RolePort[] }) {
104
+ const apart = portApartFrom(input.reserved, DEFAULT_METRICS_PORT);
105
+ super({
106
+ code: 'X_PORT_IN_USE',
107
+ cause: `the metrics port ${input.port} is this process's ${input.with.role} port ${input.with.port}, so two listeners would answer on one number`,
108
+ fix: `METRICS_PORT=${quoteArg(String(apart))} x dev --json`,
109
+ });
110
+ }
111
+ }
112
+
58
113
  /**
59
114
  * Bun surfaces the bind failure as an `Error` carrying the libc code; nothing else is ours. Read
60
115
  * through `stringField`, never `error instanceof Error` plus a property access: both run on a value
@@ -83,6 +138,8 @@ export interface MetricsEndpointOptions {
83
138
  * `x dev` with no `METRICS_PORT`, where two apps on one laptop both default to 9090.
84
139
  */
85
140
  readonly whenTaken?: 'refuse' | 'free';
141
+ /** The ports this process's roles bind (web, the sync node beside it): refused as the scrape port. */
142
+ readonly reserved?: readonly RolePort[];
86
143
  }
87
144
 
88
145
  /**
@@ -165,7 +222,18 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
165
222
  // — no code, no `fix:` — at exactly the boot path the refusal below exists to stop reporting
166
223
  // that way. Floor 0, because 0 asks the kernel for a free port and `role-start.ts` passes it for
167
224
  // an ephemeral boot; the ceiling stays Bun's, which names the range it refuses.
168
- const port = finiteCount('startMetricsEndpoint', 'port', options.port ?? DEFAULT_METRICS_PORT);
225
+ const asked = finiteCount('startMetricsEndpoint', 'port', options.port ?? DEFAULT_METRICS_PORT);
226
+ const shared = asked === 0 ? undefined : options.reserved?.find((one) => one.port === asked);
227
+ // `free` is the laptop that named no port: a role on the default is the same "taken" as another
228
+ // app on it, and the answer is the same free port. A port somebody declared is refused.
229
+ if (shared !== undefined && options.whenTaken !== 'free') {
230
+ throw new MetricsPortSharedError({
231
+ port: asked,
232
+ with: shared,
233
+ reserved: options.reserved ?? [],
234
+ });
235
+ }
236
+ const port = shared === undefined ? asked : 0;
169
237
  let buildId: string | null = null;
170
238
  // `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
171
239
  // second `x dev` on one machine reported: no code, no fix, at the boot path this package owns.
package/src/role-start.ts CHANGED
@@ -32,6 +32,7 @@ import { errorPageHook } from './error-pages';
32
32
  import { BadFlagError, RuntimeDriverSplitError } from './errors';
33
33
  import {
34
34
  DEFAULT_METRICS_PORT,
35
+ rolePorts,
35
36
  startMetricsEndpoint,
36
37
  whenMetricsPortTaken,
37
38
  } from './metrics-endpoint';
@@ -287,6 +288,7 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
287
288
  // `x dev` is one process running several roles: its `process_info` says which.
288
289
  role: selected.join('+'),
289
290
  whenTaken: whenMetricsPortTaken(binding.dev, options.env),
291
+ reserved: rolePorts(options.port, selected),
290
292
  });
291
293
  started.push(async () => metrics.stop());
292
294
  // Opened here or adopted from `serve-boot.ts`, this is where the build id is known (#734).
package/src/role-sync.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  SocketRegistry,
24
24
  syncNode,
25
25
  } from '@ultimat3/realtime/server';
26
- import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
26
+ import { portPairAfter, syncPortFor } from './flag-number';
27
27
  import { portFree } from './port-probe';
28
28
  import type { StartRolesOptions } from './role-start';
29
29
  import { startOrWaitForBus } from './role-sync-bus-wait';
@@ -35,23 +35,6 @@ import { syncOriginsFrom } from './sync-url';
35
35
  import { trustedHopsFromEnv } from './trusted-hops';
36
36
  import { DEV_BINDING } from './web-binding';
37
37
 
38
- /**
39
- * Beside its one thrower rather than in `errors.ts`, which is at 461 of the 500-line ceiling —
40
- * the arrangement `db-seed.ts` and `metrics-endpoint.ts` already take. The code is
41
- * `X_PORT_INVALID`, this package's own: "the port asked for is not one" is what it already means,
42
- * and a second code for the same fact is the synonym the registry exists to prevent.
43
- */
44
- class SyncPortUnavailableError extends UltimateError {
45
- constructor(input: { port: number }) {
46
- super({
47
- code: 'X_PORT_INVALID',
48
- cause: `the sync role binds PORT + 1, and PORT=${input.port} is the top of the range — it would ask for ${input.port + 1}, which is not a TCP port`,
49
- fix: `x dev --port ${neighbouringPort(input.port)} # leaves ${PORT_RANGE.max} free for the sync node`,
50
- meta: { port: input.port },
51
- });
52
- }
53
- }
54
-
55
38
  /**
56
39
  * The neighbour was already listening. `X_PORT_IN_USE` is this package's own and is exactly what
57
40
  * `x doctor` reports for the same condition, so one taken port has one name wherever it is found.
@@ -96,22 +79,8 @@ export async function syncBindRefusal(
96
79
  return new SyncPortInUseError({ port, webPort });
97
80
  }
98
81
 
99
- /**
100
- * The port the sync node listens on. `PORT + 1`, and `0` stays `0` — the kernel picks, and adding
101
- * one to it would pick a specific port instead.
102
- *
103
- * REFUSED at the top of the range, never clamped. `PORT_RANGE.max` is 65535 and `portValue`
104
- * accepts it, so `x dev --port 65535` handed `Bun.serve` 65536 and the bare `RangeError` reached
105
- * the terminal as `X_CLI_UNEXPECTED` with `fix: x doctor --json`. Clamping to 65534 would be worse
106
- * than refusing: `PORT + 1` is the rule `docker/docker-compose.prod.yml` publishes `3001:3001`
107
- * from and `docker/helm` derives `PORT = .port - 1` from, so a node quietly on `PORT - 1` is a
108
- * socket nothing else in the deployment computes.
109
- */
110
- export function syncPortFor(port: number): number {
111
- if (port === 0) return 0;
112
- if (port >= PORT_RANGE.max) throw new SyncPortUnavailableError({ port });
113
- return port + 1;
114
- }
82
+ // Its rule lives in `flag-number.ts`, where the scrape port's reservation reads it too (`rolePorts`).
83
+ export { syncPortFor };
115
84
 
116
85
  /**
117
86
  * A node that is built and subscribed but bound to nothing yet — the one moment the `web` role can
package/src/serve.ts CHANGED
@@ -9,7 +9,7 @@ import { assertNoSchemaDrift, checkDrift, migrate } from '@ultimat3/db';
9
9
  import { loadAppRuntime } from './app-runtime';
10
10
  import { acceptCreatedTables } from './db-accept-created';
11
11
  import { type BootedHold, holdWhileBooting } from './hold';
12
- import { startMetricsEndpoint } from './metrics-endpoint';
12
+ import { rolePorts, startMetricsEndpoint } from './metrics-endpoint';
13
13
  import { readMigrations } from './migrations';
14
14
  import { resolveServices } from './runtime-bindings';
15
15
  import { startQueue } from './runtime-queue';
@@ -143,6 +143,9 @@ export async function serveApp(input: ServeOptions): Promise<ServedApp> {
143
143
  port: metricsPortFor(options.env, port, options.metricsPort),
144
144
  hostname: containerBinding(options.env, options.hostname).hostname,
145
145
  role,
146
+ // Opened before the roles are, so the check `startRoles` makes is made here: adopted, the
147
+ // listener is never re-checked, and one on the web or sync port would take it first.
148
+ reserved: rolePorts(port, [role]),
146
149
  });
147
150
  // Everything acquired from here down, in order, so a throw anywhere below gives it all back.
148
151
  const acquired: (() => void | Promise<void>)[] = [() => metrics.stop()];