@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 +28 -28
- package/src/flag-number.ts +35 -0
- package/src/metrics-endpoint.ts +70 -2
- package/src/role-start.ts +2 -0
- package/src/role-sync.ts +3 -34
- package/src/serve.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.10.
|
|
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.
|
|
43
|
-
"@ultimat3/admin": "27.10.
|
|
44
|
-
"@ultimat3/ai": "27.10.
|
|
45
|
-
"@ultimat3/auth": "27.10.
|
|
46
|
-
"@ultimat3/cache": "27.10.
|
|
47
|
-
"@ultimat3/core": "27.10.
|
|
48
|
-
"@ultimat3/db": "27.10.
|
|
49
|
-
"@ultimat3/entity": "27.10.
|
|
50
|
-
"@ultimat3/flags": "27.10.
|
|
51
|
-
"@ultimat3/http": "27.10.
|
|
52
|
-
"@ultimat3/i18n": "27.10.
|
|
53
|
-
"@ultimat3/jobs": "27.10.
|
|
54
|
-
"@ultimat3/mail": "27.10.
|
|
55
|
-
"@ultimat3/manifest": "27.10.
|
|
56
|
-
"@ultimat3/mcp": "27.10.
|
|
57
|
-
"@ultimat3/money": "27.10.
|
|
58
|
-
"@ultimat3/notify": "27.10.
|
|
59
|
-
"@ultimat3/policy": "27.10.
|
|
60
|
-
"@ultimat3/pwa": "27.10.
|
|
61
|
-
"@ultimat3/query": "27.10.
|
|
62
|
-
"@ultimat3/realtime": "27.10.
|
|
63
|
-
"@ultimat3/render": "27.10.
|
|
64
|
-
"@ultimat3/schema": "27.10.
|
|
65
|
-
"@ultimat3/seo": "27.10.
|
|
66
|
-
"@ultimat3/storage": "27.10.
|
|
67
|
-
"@ultimat3/testing": "27.10.
|
|
68
|
-
"@ultimat3/time": "27.10.
|
|
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
|
}
|
package/src/flag-number.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/metrics-endpoint.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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()];
|