@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 +23 -23
- package/src/app-load.ts +10 -0
- package/src/cmd-build.ts +28 -4
- package/src/cmd-dev.ts +4 -1
- package/src/dev-render.ts +8 -4
- package/src/dev-roles.ts +40 -2
- package/src/dev-services.ts +6 -1
- package/src/errors.ts +59 -0
- package/src/index.ts +30 -4
- package/src/mcp-errors.ts +6 -0
- package/src/metrics-endpoint.ts +72 -0
- package/src/migrations.ts +45 -0
- package/src/prerender.ts +71 -0
- package/src/serve.ts +200 -0
- package/src/templates/index.ts +1 -0
- package/src/templates/scaffold-app.ts +55 -0
- package/src/templates/scaffold-container.ts +264 -0
- package/src/templates/scaffold-docs.ts +5 -27
- package/src/templates/scaffold-repo.ts +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
38
|
-
"@ultimat3/admin": "1.
|
|
39
|
-
"@ultimat3/ai": "1.
|
|
40
|
-
"@ultimat3/cache": "1.
|
|
41
|
-
"@ultimat3/core": "1.
|
|
42
|
-
"@ultimat3/db": "1.
|
|
43
|
-
"@ultimat3/entity": "1.
|
|
44
|
-
"@ultimat3/http": "1.
|
|
45
|
-
"@ultimat3/i18n": "1.
|
|
46
|
-
"@ultimat3/jobs": "1.
|
|
47
|
-
"@ultimat3/mail": "1.
|
|
48
|
-
"@ultimat3/manifest": "1.
|
|
49
|
-
"@ultimat3/mcp": "1.
|
|
50
|
-
"@ultimat3/policy": "1.
|
|
51
|
-
"@ultimat3/pwa": "1.
|
|
52
|
-
"@ultimat3/query": "1.
|
|
53
|
-
"@ultimat3/realtime": "1.
|
|
54
|
-
"@ultimat3/render": "1.
|
|
55
|
-
"@ultimat3/seo": "1.
|
|
56
|
-
"@ultimat3/storage": "1.
|
|
57
|
-
"@ultimat3/testing": "1.
|
|
58
|
-
"@ultimat3/time": "1.
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
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, () =>
|
|
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 }, () =>
|
|
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:
|
|
134
|
+
dev: binding.dev,
|
|
109
135
|
buildId: options.buildId,
|
|
110
|
-
hostname:
|
|
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) {
|
package/src/dev-services.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|