@ultimat3/cli 25.1.0 → 26.0.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/README.md +3 -3
- package/package.json +28 -28
- package/src/cmd-db-branch.ts +15 -11
- package/src/cmd-db-spec.ts +2 -2
- package/src/cmd-deploy-drain.ts +6 -1
- package/src/cmd-docs.ts +1 -1
- package/src/cmd-jobs-spec.ts +11 -7
- package/src/cmd-jobs.ts +10 -10
- package/src/cmd-planned.ts +3 -3
- package/src/cmd-registries-spec.ts +6 -6
- package/src/cmd-registries.ts +6 -6
- package/src/cmd-routes-spec.ts +1 -1
- package/src/cmd-routes.ts +9 -52
- package/src/cmd-tasks.ts +2 -2
- package/src/db-branch.ts +6 -6
- package/src/db-finding.ts +1 -1
- package/src/dev-restart-retire.ts +3 -1
- package/src/doctor-guards.ts +74 -19
- package/src/document-styles.ts +2 -4
- package/src/error-contract.ts +1 -1
- package/src/errors.ts +4 -4
- package/src/fix-command.ts +1 -1
- package/src/index.ts +6 -6
- package/src/jobs-report.ts +3 -3
- package/src/jobs-table.ts +1 -1
- package/src/live-routes.ts +0 -1
- package/src/mcp-db-target.ts +1 -1
- package/src/mcp-errors.ts +2 -2
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +4 -2
- package/src/parse.ts +23 -2
- package/src/role-replicator.ts +1 -1
- package/src/route-table.ts +113 -0
- package/src/runtime-jobs.ts +1 -1
- package/src/serve-boot.ts +8 -2
- package/src/serve-drain.ts +5 -1
- package/src/serve-retire.ts +7 -3
- package/src/site-seo.ts +1 -4
- package/src/templates/scaffold-claude-agents.ts +4 -4
- package/src/templates/scaffold-claude-commands.ts +2 -2
- package/src/templates/scaffold-claude.ts +1 -1
- package/src/templates/scaffold-guards.ts +12 -0
- package/src/templates/scaffold-helm-templates.ts +6 -2
- package/src/templates/scaffold-helm.ts +4 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// login) ran twice. The worker is retired first — `stop()` claims nothing and aborts nothing — and
|
|
5
5
|
// only then is the drain asked to release the port, the lock and the embedded database.
|
|
6
6
|
|
|
7
|
-
import { drain, logger, renderThrowable } from '@ultimat3/core';
|
|
7
|
+
import { drain, logger, markRetiring, renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import type { Worker } from '@ultimat3/jobs';
|
|
9
9
|
import { msg } from './messages';
|
|
10
10
|
import { writeErrorLine } from './write-line';
|
|
@@ -32,6 +32,8 @@ export async function retireThenDrain(
|
|
|
32
32
|
worker: RetiringWorker | null,
|
|
33
33
|
deps: RetireDeps = DEFAULT_DEPS,
|
|
34
34
|
): Promise<void> {
|
|
35
|
+
// The same retire the production SIGUSR2 is (`serve-retire.ts`), so the app reads it the same way.
|
|
36
|
+
markRetiring();
|
|
35
37
|
try {
|
|
36
38
|
if (worker !== null) {
|
|
37
39
|
const { inFlight } = await worker.stats();
|
package/src/doctor-guards.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// `x doctor`'s guard listing: the guards
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// `x doctor`'s guard listing: the shipped guards this app's `guards/` does not hold, and the ones
|
|
2
|
+
// it holds in a form that is not the current template — each with the command that writes it. A
|
|
3
|
+
// LISTING, never a finding: every shipped guard is the app's copy ("delete this file to drop the
|
|
4
|
+
// rule"), so a missing or edited one may be a decision the framework must not overrule.
|
|
5
5
|
|
|
6
|
+
// why: Bun exposes no path-join primitive; Bun.file takes one already joined.
|
|
7
|
+
import { join } from 'node:path';
|
|
6
8
|
import { GUARD_DIR, guardPaths } from './guards';
|
|
7
9
|
import { msg } from './messages';
|
|
8
10
|
import type { CommandResult, JsonValue } from './output';
|
|
9
|
-
import { SHIPPED_GUARD_NAMES } from './templates/scaffold-guards';
|
|
11
|
+
import { SHIPPED_GUARD_NAMES, shippedGuardSource } from './templates/scaffold-guards';
|
|
10
12
|
|
|
11
13
|
export interface MissingGuard {
|
|
12
14
|
readonly name: string;
|
|
@@ -23,9 +25,51 @@ export function missingShippedGuards(present: readonly string[]): readonly Missi
|
|
|
23
25
|
}));
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
export interface DifferingGuard {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
/** Rewrites the guard and its test from the current template. Review the diff, then keep it. */
|
|
31
|
+
readonly refresh: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One line of the listing: a shipped guard the app lacks, or holds in another form. */
|
|
35
|
+
export type GuardListingEntry = MissingGuard | DifferingGuard;
|
|
36
|
+
|
|
37
|
+
/** A guard file as the app holds it, path relative to the app root. */
|
|
38
|
+
export interface HeldGuard {
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly source: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A checkout on Windows may write CRLF; the rule is the same text either way. */
|
|
44
|
+
const sameText = (a: string, b: string): boolean =>
|
|
45
|
+
a.replaceAll('\r\n', '\n') === b.replaceAll('\r\n', '\n');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The rule, pure: which held guards carry a shipped name and are not that guard's current
|
|
49
|
+
* template. A guard with a name the framework never shipped is the app's own, and has no template.
|
|
50
|
+
*/
|
|
51
|
+
export function differingShippedGuards(held: readonly HeldGuard[]): readonly DifferingGuard[] {
|
|
52
|
+
const differing: DifferingGuard[] = [];
|
|
53
|
+
for (const { path, source } of held) {
|
|
54
|
+
const name = path.slice(`${GUARD_DIR}/`.length).replace(/\.tsx?$/, '');
|
|
55
|
+
const template = shippedGuardSource(name);
|
|
56
|
+
if (template === undefined || sameText(source, template)) continue;
|
|
57
|
+
differing.push({ name, refresh: `x g guard ${name} --force` });
|
|
58
|
+
}
|
|
59
|
+
return differing;
|
|
60
|
+
}
|
|
61
|
+
|
|
26
62
|
/** The I/O half: the same enumeration the `boundaries` step runs, so the two cannot disagree. */
|
|
27
|
-
export async function shippedGuardsProbe(root: string): Promise<readonly
|
|
28
|
-
|
|
63
|
+
export async function shippedGuardsProbe(root: string): Promise<readonly GuardListingEntry[]> {
|
|
64
|
+
const paths = await guardPaths(root);
|
|
65
|
+
const held = await Promise.all(
|
|
66
|
+
paths.map(async (path) => ({ path, source: await Bun.file(join(root, path)).text() })),
|
|
67
|
+
);
|
|
68
|
+
const entries: GuardListingEntry[] = [
|
|
69
|
+
...missingShippedGuards(paths),
|
|
70
|
+
...differingShippedGuards(held),
|
|
71
|
+
];
|
|
72
|
+
return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
29
73
|
}
|
|
30
74
|
|
|
31
75
|
/**
|
|
@@ -34,21 +78,32 @@ export async function shippedGuardsProbe(root: string): Promise<readonly Missing
|
|
|
34
78
|
*/
|
|
35
79
|
export function withGuardListing(
|
|
36
80
|
result: CommandResult,
|
|
37
|
-
|
|
81
|
+
entries: readonly GuardListingEntry[],
|
|
38
82
|
): CommandResult {
|
|
39
83
|
const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
|
|
40
|
-
const
|
|
84
|
+
const missing = entries.filter((entry): entry is MissingGuard => 'add' in entry);
|
|
85
|
+
const differs = entries.filter((entry): entry is DifferingGuard => 'refresh' in entry);
|
|
86
|
+
const lines = [
|
|
87
|
+
...(missing.length === 0
|
|
88
|
+
? []
|
|
89
|
+
: [
|
|
90
|
+
msg('cli.doctor.guards', { count: missing.length }),
|
|
91
|
+
...missing.map(({ add }) => ` ${add}`),
|
|
92
|
+
]),
|
|
93
|
+
...(differs.length === 0
|
|
94
|
+
? []
|
|
95
|
+
: [
|
|
96
|
+
msg('cli.doctor.guardsDiffer', { count: differs.length }),
|
|
97
|
+
...differs.map(({ refresh }) => ` ${refresh}`),
|
|
98
|
+
]),
|
|
99
|
+
];
|
|
100
|
+
const guards: JsonValue = {
|
|
101
|
+
missing: missing.map(({ name, add }) => ({ name, add })),
|
|
102
|
+
differs: differs.map(({ name, refresh }) => ({ name, refresh })),
|
|
103
|
+
};
|
|
41
104
|
return {
|
|
42
105
|
...result,
|
|
43
|
-
data: { ...(data as Record<string, JsonValue>), guards
|
|
44
|
-
...(
|
|
45
|
-
? {}
|
|
46
|
-
: {
|
|
47
|
-
lines: [
|
|
48
|
-
...(result.lines ?? []),
|
|
49
|
-
msg('cli.doctor.guards', { count: missing.length }),
|
|
50
|
-
...missing.map(({ add }) => ` ${add}`),
|
|
51
|
-
],
|
|
52
|
-
}),
|
|
106
|
+
data: { ...(data as Record<string, JsonValue>), guards },
|
|
107
|
+
...(lines.length === 0 ? {} : { lines: [...(result.lines ?? []), ...lines] }),
|
|
53
108
|
};
|
|
54
109
|
}
|
package/src/document-styles.ts
CHANGED
|
@@ -30,14 +30,12 @@ export interface SurfaceDocument {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Every surface that renders a document, with the CSS one would carry
|
|
34
|
-
*
|
|
35
|
-
* tokens. Read from render's own registry, filled when the CLI loaded the app: a second walk of
|
|
33
|
+
* Every surface that renders a document, with the CSS one would carry — a route lives on `site/`
|
|
34
|
+
* or `app/` only, so `api/` (no document) never appears. Read from render's own registry, filled when the CLI loaded the app: a second walk of
|
|
36
35
|
* the app's stylesheets here would be a second answer to "what does this document contain".
|
|
37
36
|
*/
|
|
38
37
|
export function documentSurfaces(): readonly SurfaceDocument[] {
|
|
39
38
|
const surfaces = new Set(routeEntries().map((entry) => entry.surface));
|
|
40
|
-
surfaces.delete('api');
|
|
41
39
|
return [...surfaces]
|
|
42
40
|
.sort()
|
|
43
41
|
.map((surface) => ({ surface, css: stylesFor(surface) }) satisfies SurfaceDocument);
|
package/src/error-contract.ts
CHANGED
|
@@ -32,7 +32,7 @@ export const BANNED_PHRASES: readonly RegExp[] = [
|
|
|
32
32
|
/**
|
|
33
33
|
* What makes a fix actionable as written: the `x` CLI, a tool the machine already has, a call the
|
|
34
34
|
* reader can paste, or a file they can open. A banned phrase is only a failure without one of
|
|
35
|
-
* these — "check the gateway, then: x actions
|
|
35
|
+
* these — "check the gateway, then: x actions show posts.publish --json" names the observation
|
|
36
36
|
* *and* the command, which is the shape the contract wants.
|
|
37
37
|
*/
|
|
38
38
|
export const COMMAND_TOKENS: readonly RegExp[] = [
|
package/src/errors.ts
CHANGED
|
@@ -248,7 +248,7 @@ export class ErrorCodeUnknownError extends UltimateError {
|
|
|
248
248
|
}
|
|
249
249
|
|
|
250
250
|
/**
|
|
251
|
-
* `x actions|queries|entities
|
|
251
|
+
* `x actions|queries|entities|tasks show <name>` named a declaration the registries do not hold —
|
|
252
252
|
* a typo, or a module that never imported. `known` is the count, not the list: a 200-action app
|
|
253
253
|
* would bury the fix line under names nobody asked for, and `list` is one command away.
|
|
254
254
|
*/
|
|
@@ -259,7 +259,7 @@ export class DeclarationUnknownError extends UltimateError {
|
|
|
259
259
|
name: string;
|
|
260
260
|
known: readonly string[];
|
|
261
261
|
suggestion?: string;
|
|
262
|
-
/** The subcommand that takes one name
|
|
262
|
+
/** The subcommand that takes one name: `show` (#709), or `explain` for `x policy` — the "why". */
|
|
263
263
|
verb?: string;
|
|
264
264
|
}) {
|
|
265
265
|
super({
|
|
@@ -268,7 +268,7 @@ export class DeclarationUnknownError extends UltimateError {
|
|
|
268
268
|
fix:
|
|
269
269
|
input.suggestion === undefined
|
|
270
270
|
? `x ${input.kind} list --json`
|
|
271
|
-
: `x ${input.kind} ${input.verb ?? '
|
|
271
|
+
: `x ${input.kind} ${input.verb ?? 'show'} ${input.suggestion}`,
|
|
272
272
|
});
|
|
273
273
|
}
|
|
274
274
|
}
|
|
@@ -279,7 +279,7 @@ export class JobUnknownError extends UltimateError {
|
|
|
279
279
|
super({
|
|
280
280
|
code: 'X_JOB_UNKNOWN',
|
|
281
281
|
cause: `the "${input.driver}" queue holds no job with id "${input.id}"`,
|
|
282
|
-
fix: 'x jobs
|
|
282
|
+
fix: 'x jobs list --json',
|
|
283
283
|
});
|
|
284
284
|
}
|
|
285
285
|
}
|
package/src/fix-command.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { GLOBAL_FLAGS } from './parse';
|
|
|
27
27
|
// slot where the reader has nothing to substitute, so `x db branch <name>` — two shipped fix lines
|
|
28
28
|
// in `@ultimat3/mcp` — is `X_CLI_UNKNOWN_COMMAND` when run and resolved clean while a placeholder
|
|
29
29
|
// was invisible to the reader. Second and fourth slots are open positionals (`x new my-app`,
|
|
30
|
-
// `x db branch
|
|
30
|
+
// `x db branch delete <name>`), where a placeholder is exactly right.
|
|
31
31
|
// A `:` is part of a word only when a letter follows it, which is what separates the shipped
|
|
32
32
|
// positional `admin:page` from prose that ends a citation with a colon (`x verify: the gate`).
|
|
33
33
|
// Read without it, `x g admin:page` cites `x g admin` — a positional the CLI does not ship —
|
package/src/index.ts
CHANGED
|
@@ -31,12 +31,6 @@ export { GENERATORS, generate, writeFiles } from './cmd-generate';
|
|
|
31
31
|
export { JOBS_SUBCOMMANDS } from './cmd-jobs';
|
|
32
32
|
export { newCommand, planNewApp, writeNewApp } from './cmd-new';
|
|
33
33
|
export { PLANNED_COMMANDS, PLANNED_SUBCOMMANDS, plannedSubcommand } from './cmd-planned';
|
|
34
|
-
// `shotCommand`, `prCommand` and `ciCommand` are deliberately NOT re-exported here. They reach
|
|
35
|
-
// `x` through `registry.ts`, which is the only thing that makes a command exist — and the barrel
|
|
36
|
-
// is the surface an APP imports. Exporting them would put the browser driver and the GitHub client
|
|
37
|
-
// in the module graph of every app that imports `@ultimat3/cli`, for tools it never calls. The app
|
|
38
|
-
// path does not pay for the tool path.
|
|
39
|
-
export { renderRouteTable } from './cmd-routes';
|
|
40
34
|
export { runVerify, VERIFY_STEPS, verifyCommand, verifyStepNames } from './cmd-verify';
|
|
41
35
|
export type { CliCommand } from './command';
|
|
42
36
|
export { failed, ok } from './command';
|
|
@@ -130,6 +124,12 @@ export { loadPwaArtifacts } from './pwa-artifacts';
|
|
|
130
124
|
export { COMMANDS, cliVersion, SPECS } from './registry';
|
|
131
125
|
export type { RunningRoles, WebBinding } from './role-start';
|
|
132
126
|
export { DEV_BINDING, DEV_ROLES, startRoles } from './role-start';
|
|
127
|
+
// `shotCommand`, `prCommand` and `ciCommand` are deliberately NOT re-exported here. They reach
|
|
128
|
+
// `x` through `registry.ts`, which is the only thing that makes a command exist — and the barrel
|
|
129
|
+
// is the surface an APP imports. Exporting them would put the browser driver and the GitHub client
|
|
130
|
+
// in the module graph of every app that imports `@ultimat3/cli`, for tools it never calls. The app
|
|
131
|
+
// path does not pay for the tool path.
|
|
132
|
+
export { renderRouteTable } from './route-table';
|
|
133
133
|
export { assetRoutes, MEDIA_BASE_PATH } from './runtime-assets';
|
|
134
134
|
export { resolveServices } from './runtime-bindings';
|
|
135
135
|
export { devHooks } from './runtime-hooks';
|
package/src/jobs-report.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Pure, driver-injected job operations behind `x jobs`: flag parsing, plus
|
|
1
|
+
// Pure, driver-injected job operations behind `x jobs`: flag parsing, plus list / show / retry. No
|
|
2
2
|
// CLI parsing, no process I/O, no rendering — a test drives every path with `memoryJobDriver()`
|
|
3
3
|
// alone. The `--json` shapes are `jobs-json.ts`, the table `jobs-table.ts`.
|
|
4
4
|
|
|
@@ -61,7 +61,7 @@ export function parseLimitFlag(value: string | undefined, command = 'jobs'): num
|
|
|
61
61
|
return limit;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// ──
|
|
64
|
+
// ── list ──────────────────────────────────────────────────────────────────
|
|
65
65
|
|
|
66
66
|
export interface JobsListFilter {
|
|
67
67
|
readonly queue?: string | undefined;
|
|
@@ -105,7 +105,7 @@ async function jobPage(driver: JobDriver, filter: JobFilter): Promise<Page<JobRe
|
|
|
105
105
|
* `--state ready` filter (or the default 100-row cap) pushes out of view is the exact failure
|
|
106
106
|
* mode this command exists to prevent.
|
|
107
107
|
*
|
|
108
|
-
* Backfills are read `running` only. `x jobs
|
|
108
|
+
* Backfills are read `running` only. `x jobs list` is a LIVE view of the queue — a pass that
|
|
109
109
|
* finished last week is history, and `x db backfill --list` is where that question is asked and
|
|
110
110
|
* answered with the whole ledger. A driver with no ledger answers `[]` rather than throwing, so
|
|
111
111
|
* the queue view never fails over a fact nobody asked about.
|
package/src/jobs-table.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The `x jobs
|
|
1
|
+
// The `x jobs list` terminal table, and nothing else. Split out of `jobs-report.ts` so that
|
|
2
2
|
// deciding how a value LOOKS lives apart from deciding what the queue DOES: a rendering rule
|
|
3
3
|
// (column order, padding, the run-at unit) is reviewed here, once, with no queue logic around it.
|
|
4
4
|
|
package/src/live-routes.ts
CHANGED
|
@@ -183,7 +183,6 @@ export async function liveRouteGaps(
|
|
|
183
183
|
const reported = new Set<string>();
|
|
184
184
|
const gaps: LiveRouteGap[] = [];
|
|
185
185
|
for (const entry of entries) {
|
|
186
|
-
if (entry.surface === 'api') continue;
|
|
187
186
|
const never = entry.config.hydrate === 'never';
|
|
188
187
|
for (const read of await browserOnlyReads(root, entry.file)) {
|
|
189
188
|
if (reported.has(read.at) || (!never && inBrowser.has(read.at))) continue;
|
package/src/mcp-db-target.ts
CHANGED
|
@@ -56,7 +56,7 @@ const isProduction = (env: Env): boolean => {
|
|
|
56
56
|
/**
|
|
57
57
|
* `x db branch create <name>` names an external clone `<source>_branch_<name>`. Both readings come
|
|
58
58
|
* from `db-branch.ts` — the module that also WRITES those names — because a target that disagrees
|
|
59
|
-
* with `x db branch
|
|
59
|
+
* with `x db branch list` about what a branch is would let `db.migrate` run against a shared
|
|
60
60
|
* database on the strength of a naming rule one of the two had drifted away from.
|
|
61
61
|
*/
|
|
62
62
|
function postgresBranch(url: string): string | null {
|
package/src/mcp-errors.ts
CHANGED
|
@@ -60,7 +60,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
60
60
|
X_APP_PACKAGE_INVALID: 'bun pm pkg set name=my-app version=0.1.0',
|
|
61
61
|
X_ERROR_CODE_UNKNOWN: 'x errors list --json',
|
|
62
62
|
X_DECLARATION_UNKNOWN: 'x actions list --json',
|
|
63
|
-
X_JOB_UNKNOWN: 'x jobs
|
|
63
|
+
X_JOB_UNKNOWN: 'x jobs list --json',
|
|
64
64
|
X_FIX_TARGET_UNKNOWN: 'x fix boundary apps/web/site/page.tsx --json',
|
|
65
65
|
X_ERROR_FIX_INVALID: 'x verify --json # the finding names the file, the line and the fix text',
|
|
66
66
|
X_ERROR_FIX_PATH_MISSING:
|
|
@@ -213,7 +213,7 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
213
213
|
X_QUERY_SUBSCRIBES_UNKNOWN:
|
|
214
214
|
'x db gen "retry after fixing subscribes" --json # first edit subscribes: on the query the cause names',
|
|
215
215
|
X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
|
|
216
|
-
X_DB_BRANCH_FAILED: 'x db branch
|
|
216
|
+
X_DB_BRANCH_FAILED: 'x db branch list --json',
|
|
217
217
|
X_DB_STUDIO_FAILED: 'x doctor --json',
|
|
218
218
|
// Runnable first, the narrowing behind a `#`, exactly as X_CLI_UNKNOWN_COMMAND above: naming
|
|
219
219
|
// the tier IS the consent, and which seed to consent to is the one thing this table cannot
|
package/src/mcp-host.ts
CHANGED
|
@@ -36,7 +36,6 @@ import type {
|
|
|
36
36
|
VerifyStep,
|
|
37
37
|
} from '@ultimat3/mcp';
|
|
38
38
|
import { DEV_SCOPES, devHost, devMcpServer, frameworkIntrospection } from '@ultimat3/mcp';
|
|
39
|
-
import { describePages } from '@ultimat3/render';
|
|
40
39
|
import { loadApp } from './app-load';
|
|
41
40
|
import { appManifest, policyFacts } from './app-manifest';
|
|
42
41
|
import { runVerify, VERIFY_STEPS } from './cmd-verify';
|
|
@@ -50,6 +49,7 @@ import { testRunOf } from './mcp-test-run';
|
|
|
50
49
|
import { type UiCapabilities, uiCapabilities } from './mcp-ui';
|
|
51
50
|
import { readMigrations } from './migrations';
|
|
52
51
|
import { retryMemo } from './retry-memo';
|
|
52
|
+
import { plainAppRoutes, routeRows } from './route-table';
|
|
53
53
|
import type { DevServices, Env } from './runtime-bindings';
|
|
54
54
|
import { resolveServices } from './runtime-bindings';
|
|
55
55
|
import type { RunningServices } from './runtime-services';
|
|
@@ -346,10 +346,12 @@ export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpSer
|
|
|
346
346
|
// scratch boot re-imported the app after declaring — rendered. Idempotent; a set key stays.
|
|
347
347
|
declareDevEnvironment(input.env);
|
|
348
348
|
await Promise.all([loadApp(input.root), loadCodeFixes()]);
|
|
349
|
+
// The plain `runtime.ts` routes, read once: `routes.list` answers the table `x routes` prints.
|
|
350
|
+
const plain = await plainAppRoutes(input.root);
|
|
349
351
|
const lazy = lazyServices(input);
|
|
350
352
|
const ui = uiCapabilities({ root: input.root, env: input.env });
|
|
351
353
|
const introspection = frameworkIntrospection({
|
|
352
|
-
routes: () =>
|
|
354
|
+
routes: () => routeRows(plain).map((row) => row.json),
|
|
353
355
|
policies: () => policyFacts(),
|
|
354
356
|
});
|
|
355
357
|
const server = devMcpServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
|
package/src/messages.ts
CHANGED
|
@@ -46,7 +46,7 @@ const CATALOG = {
|
|
|
46
46
|
'cli.db.branch.failed': 'branch command failed',
|
|
47
47
|
'cli.db.branch.listed': '{count} branch(es) of this database',
|
|
48
48
|
'cli.db.branch.none': 'this database has no branch',
|
|
49
|
-
/** The empty cell in an `x db branch
|
|
49
|
+
/** The empty cell in an `x db branch list` column — a value, not a column key. */
|
|
50
50
|
'cli.db.branch.unknown': '-',
|
|
51
51
|
'cli.db.gen.failed': 'migration not generated',
|
|
52
52
|
'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
|
|
@@ -102,6 +102,8 @@ const CATALOG = {
|
|
|
102
102
|
'cli.doctor.findings': '{count} finding(s)',
|
|
103
103
|
'cli.doctor.guards':
|
|
104
104
|
'{count} shipped guard(s) this app does not hold — each line adds one, then x verify reports what it finds:',
|
|
105
|
+
'cli.doctor.guardsDiffer':
|
|
106
|
+
'{count} shipped guard(s) differ from the current template — an older copy or your own edit; each line rewrites one and its test, review the diff before keeping it:',
|
|
105
107
|
// `x doctor`'s other listing: what the coverage floor in x.verify.json does not see, and why.
|
|
106
108
|
'cli.doctor.coverageExclude':
|
|
107
109
|
'{count} path(s) x.verify.json excludes from the coverage floor — each with the reason it gives:',
|
|
@@ -147,7 +149,7 @@ const CATALOG = {
|
|
|
147
149
|
'cli.jobs.depth':
|
|
148
150
|
'{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
|
|
149
151
|
'cli.jobs.listed': '{count} job(s)',
|
|
150
|
-
'cli.jobs.nextPage': 'more — next page: x jobs
|
|
152
|
+
'cli.jobs.nextPage': 'more — next page: x jobs list --after {cursor}',
|
|
151
153
|
'cli.jobs.noError': 'no error recorded',
|
|
152
154
|
'cli.jobs.paused': 'queue {queue} paused — no worker claims from it; enqueues still land',
|
|
153
155
|
'cli.jobs.pausedQueues': 'paused queue(s): {queues}',
|
package/src/parse.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface CommandSpec {
|
|
|
44
44
|
* than a misspelt one. Declared per command, never inferred, because only some commands can say
|
|
45
45
|
* it truthfully: `x errors X_PERMISSION_UNKNOWN` can only be a code, and `x jobs 4f2a` is
|
|
46
46
|
* genuinely ambiguous with `show`, so an unconditional fallback would turn `x jobs <id>` into a
|
|
47
|
-
* silent `x jobs
|
|
47
|
+
* silent `x jobs list` that ignores the id.
|
|
48
48
|
*
|
|
49
49
|
* `x errors X_PERMISSION_UNKNOWN --json` answered `X_CLI_UNKNOWN_COMMAND … fix: x help`, and
|
|
50
50
|
* `x help` prints `errors an X_* code, explained` — which reads as exactly the form that was
|
|
@@ -304,6 +304,27 @@ function splitInline(raw: string): [string, string | undefined] {
|
|
|
304
304
|
return [raw.slice(0, eq), raw.slice(eq + 1)];
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/**
|
|
308
|
+
* The verbs 26.0.0 retired (#709): one verb each across every `x` registry — `list`, `show <one>`,
|
|
309
|
+
* `delete` — and no alias, because two spellings of one verb is the ambiguity axiom 1 forbids. A
|
|
310
|
+
* retired verb is REFUSED, never accepted; this table only lets the refusal name its replacement.
|
|
311
|
+
*/
|
|
312
|
+
export const RETIRED_VERBS = Object.freeze<Record<string, string>>({
|
|
313
|
+
ls: 'list',
|
|
314
|
+
rm: 'delete',
|
|
315
|
+
describe: 'show',
|
|
316
|
+
drop: 'delete',
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
/** The verb that replaced `word`, when `word` was retired and its replacement is in `allowed`. */
|
|
320
|
+
export function retiredVerbReplacement(
|
|
321
|
+
word: string,
|
|
322
|
+
allowed: readonly string[],
|
|
323
|
+
): string | undefined {
|
|
324
|
+
const replacement = Object.hasOwn(RETIRED_VERBS, word) ? RETIRED_VERBS[word] : undefined;
|
|
325
|
+
return replacement !== undefined && allowed.includes(replacement) ? replacement : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
307
328
|
/** Which subcommand ran, and whether the caller's first positional is what named it. */
|
|
308
329
|
interface ResolvedSubcommand {
|
|
309
330
|
readonly name: string | undefined;
|
|
@@ -323,7 +344,7 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): Reso
|
|
|
323
344
|
throw new MissingSubcommandError({ command: spec.name, known: allowed });
|
|
324
345
|
}
|
|
325
346
|
if (allowed.includes(token)) return { name: token, consumed: true };
|
|
326
|
-
const suggestion = nearestName(token, allowed);
|
|
347
|
+
const suggestion = retiredVerbReplacement(token, allowed) ?? nearestName(token, allowed);
|
|
327
348
|
// The declared fallback, and only past the near-miss guard: a word within `nearestName`'s edit
|
|
328
349
|
// budget of a real subcommand is a typo, and reading it as the default subcommand's argument
|
|
329
350
|
// would answer a question nobody asked.
|
package/src/role-replicator.ts
CHANGED
|
@@ -73,7 +73,7 @@ const embeddedRefusal = (dev: boolean): BadFlagError | ConfigInvalidError =>
|
|
|
73
73
|
* Both readers of this list are catalog readers. `PgReplicationStream` keeps a change only when
|
|
74
74
|
* `#entities.has(relation.name)`, and a pgoutput Relation message names the table; `warnPartialIdentity`
|
|
75
75
|
* matches the same list against `pg_class.relname`. An entity NAME is the framework's own registry
|
|
76
|
-
* key — what a cache tag, a policy and `x entities
|
|
76
|
+
* key — what a cache tag, a policy and `x entities show` are keyed by — and `entity('user',
|
|
77
77
|
* { table: 'users' })` makes the two different strings. Passing the name meant a renamed table
|
|
78
78
|
* matched nothing on either side: every change SKIPPED, and a replica-identity warning that could
|
|
79
79
|
* never fire. Latent wherever the two agree, which is every entity in `examples/dummy`.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The served route table, as rows: the pages (`describePages()`), then every API route — the
|
|
2
|
+
// action and query projections `apiRoutes()` builds for `x dev` and the container, and the plain
|
|
3
|
+
// routes of `apps/<app>/runtime.ts`. `x routes` and the MCP dev tool `routes.list` both read it.
|
|
4
|
+
|
|
5
|
+
import type { Route } from '@ultimat3/http';
|
|
6
|
+
import type { RouteDescriptor, Surface } from '@ultimat3/render';
|
|
7
|
+
import { describePages } from '@ultimat3/render';
|
|
8
|
+
import { apiRoutes } from './api-routes';
|
|
9
|
+
import { loadAppRuntime } from './app-runtime';
|
|
10
|
+
import type { JsonValue } from './output';
|
|
11
|
+
|
|
12
|
+
/** One printed and one JSON row per served route: a page, a mounted page, or an API route. */
|
|
13
|
+
export interface RouteRow {
|
|
14
|
+
readonly surface: Surface;
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly cells: readonly string[];
|
|
17
|
+
readonly json: JsonValue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where a route comes from. A file route names its file; a MOUNTED one names the call that
|
|
22
|
+
* mounted it and every permission that gates it — there is no `page.tsx` to name, and printing the
|
|
23
|
+
* package would send a reader looking for a route file inside `node_modules`.
|
|
24
|
+
*/
|
|
25
|
+
const declaredBy = (route: RouteDescriptor): string =>
|
|
26
|
+
route.mount === undefined
|
|
27
|
+
? route.file
|
|
28
|
+
: `${route.mount.by}() · ${route.mount.permissions.join(' + ')}`;
|
|
29
|
+
|
|
30
|
+
const pageRow = (route: RouteDescriptor): RouteRow => ({
|
|
31
|
+
surface: route.surface,
|
|
32
|
+
path: route.path,
|
|
33
|
+
cells: [
|
|
34
|
+
'GET',
|
|
35
|
+
route.path,
|
|
36
|
+
route.surface,
|
|
37
|
+
route.mode,
|
|
38
|
+
route.hydrate,
|
|
39
|
+
route.offline,
|
|
40
|
+
declaredBy(route),
|
|
41
|
+
],
|
|
42
|
+
json: {
|
|
43
|
+
method: 'GET',
|
|
44
|
+
path: route.path,
|
|
45
|
+
surface: route.surface,
|
|
46
|
+
file: route.file,
|
|
47
|
+
render: route.mode,
|
|
48
|
+
hydrate: route.hydrate,
|
|
49
|
+
offline: route.offline,
|
|
50
|
+
budget: { js: route.budgetJs },
|
|
51
|
+
// Absent on a file route, never `null`: only a mounted route has the fact.
|
|
52
|
+
...(route.mount === undefined
|
|
53
|
+
? {}
|
|
54
|
+
: { mount: { by: route.mount.by, permissions: [...route.mount.permissions] } }),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* An action or query projection, or a plain `runtime.ts` route (`primitive: null`). It renders
|
|
60
|
+
* nothing, so the page columns read `-`; where a page names its file this names what declared it
|
|
61
|
+
* and the permission its policy asks for.
|
|
62
|
+
*/
|
|
63
|
+
const apiRow = (route: Route): RouteRow => {
|
|
64
|
+
const primitive = route.meta.primitive ?? null;
|
|
65
|
+
const policy = route.meta.policy ?? null;
|
|
66
|
+
const from = primitive === null ? 'runtime.ts routes' : primitive;
|
|
67
|
+
const by = `${from} ${route.meta.name}${policy === null ? '' : ` · ${policy}`}`;
|
|
68
|
+
return {
|
|
69
|
+
surface: 'api',
|
|
70
|
+
path: route.path,
|
|
71
|
+
cells: [route.method, route.path, 'api', '-', '-', '-', by],
|
|
72
|
+
json: {
|
|
73
|
+
method: route.method,
|
|
74
|
+
path: route.path,
|
|
75
|
+
surface: 'api',
|
|
76
|
+
primitive,
|
|
77
|
+
name: route.meta.name,
|
|
78
|
+
auth: route.meta.auth,
|
|
79
|
+
policy,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const byPath = (a: RouteRow, b: RouteRow): number =>
|
|
85
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
86
|
+
|
|
87
|
+
/** Pages first, in the page table's order; then the API, by path, so the listing diffs cleanly. */
|
|
88
|
+
export function routeRows(plain: readonly Route[] = []): readonly RouteRow[] {
|
|
89
|
+
return [...describePages().map(pageRow), ...[...apiRoutes(), ...plain].map(apiRow).sort(byPath)];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The plain HTTP routes `apps/<app>/runtime.ts` declares — the ones `X_BODY_INVALID` is about,
|
|
94
|
+
* since an action or query validates its own input. Loaded as `x dev` loads them.
|
|
95
|
+
*/
|
|
96
|
+
export async function plainAppRoutes(root: string): Promise<readonly Route[]> {
|
|
97
|
+
return (await loadAppRuntime(root))?.routes ?? [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Fixed-width columns so the output diffs cleanly between runs and between machines. */
|
|
101
|
+
export function renderRouteTable(routes: readonly RouteDescriptor[]): readonly string[] {
|
|
102
|
+
return renderRouteRows(routes.map(pageRow));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function renderRouteRows(rows: readonly RouteRow[]): readonly string[] {
|
|
106
|
+
const header = ['method', 'path', 'surface', 'render', 'hydrate', 'offline', 'declared by'];
|
|
107
|
+
const widths = header.map((title, index) =>
|
|
108
|
+
Math.max(title.length, ...rows.map((row) => (row.cells[index] ?? '').length)),
|
|
109
|
+
);
|
|
110
|
+
const line = (cells: readonly string[]): string =>
|
|
111
|
+
cells.map((value, index) => value.padEnd(widths[index] ?? 0)).join(' ');
|
|
112
|
+
return [line(header), ...rows.map((row) => line(row.cells))];
|
|
113
|
+
}
|
package/src/runtime-jobs.ts
CHANGED
|
@@ -93,7 +93,7 @@ function reportUnserved(served: readonly string[], registered: readonly string[]
|
|
|
93
93
|
logger.warn('jobs.worker.queue-unserved', {
|
|
94
94
|
queues,
|
|
95
95
|
served,
|
|
96
|
-
fix: `x jobs
|
|
96
|
+
fix: `x jobs list --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
package/src/serve-boot.ts
CHANGED
|
@@ -54,13 +54,19 @@ const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
|
|
|
54
54
|
* — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
|
|
55
55
|
*
|
|
56
56
|
* The budget (`deadlineMs`) is every role's: it is the time a worker's running job has to finish
|
|
57
|
-
* on a deploy, so it is the one key here a non-listening role needs MOST.
|
|
57
|
+
* on a deploy, so it is the one key here a non-listening role needs MOST. The worker's alone is
|
|
58
|
+
* `workerDeadlineMs` when declared — the hour `deadlineMs` is capped at is too short for a job
|
|
59
|
+
* that must not run twice, and a web role has no business waiting two hours on a request.
|
|
58
60
|
*/
|
|
59
61
|
export function lifecycleForRole(
|
|
60
62
|
role: Role,
|
|
61
63
|
drain: Partial<DrainConfig> | undefined,
|
|
62
64
|
): LifecycleOptions {
|
|
63
|
-
const
|
|
65
|
+
const declared =
|
|
66
|
+
role === 'worker' && drain?.workerDeadlineMs !== undefined
|
|
67
|
+
? drain.workerDeadlineMs
|
|
68
|
+
: drain?.deadlineMs;
|
|
69
|
+
const budget = declared === undefined ? {} : { deadlineMs: declared };
|
|
64
70
|
if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0, ...budget };
|
|
65
71
|
return drain?.readinessGraceMs === undefined
|
|
66
72
|
? budget
|
package/src/serve-drain.ts
CHANGED
|
@@ -9,7 +9,11 @@ export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig
|
|
|
9
9
|
const drain = (await loadAppConfig(root))?.drain;
|
|
10
10
|
return drain === undefined
|
|
11
11
|
? undefined
|
|
12
|
-
: {
|
|
12
|
+
: {
|
|
13
|
+
readinessGraceMs: drain.readinessGraceMs,
|
|
14
|
+
deadlineMs: drain.deadlineMs,
|
|
15
|
+
workerDeadlineMs: drain.workerDeadlineMs,
|
|
16
|
+
};
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
/** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
|
package/src/serve-retire.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// `ROLE=worker`'s retire, on SIGUSR2 (issue #669): stop claiming, finish every held job however
|
|
2
2
|
// long it takes, abort nothing — then the ordinary drain, so the process exits 0. It is what a
|
|
3
3
|
// Kubernetes `preStop` sends before the SIGTERM a rollout ends in, for a job whose side effect may
|
|
4
|
-
// happen at most once and may outlast
|
|
4
|
+
// happen at most once and may outlast the drain budget. Framework-owned: no port, no app code.
|
|
5
5
|
|
|
6
|
-
import { drain, logger, renderThrowable } from '@ultimat3/core';
|
|
6
|
+
import { drain, logger, markRetiring, renderThrowable } from '@ultimat3/core';
|
|
7
7
|
import type { Worker } from '@ultimat3/jobs';
|
|
8
8
|
|
|
9
9
|
/** The signal a worker retires on. Free in Bun and Node (SIGUSR1 is the inspector's). */
|
|
@@ -43,7 +43,8 @@ const NOTHING: WorkerRetire = Object.freeze({ adopt: () => undefined, disarm: ()
|
|
|
43
43
|
* Exits rather than holds, decided 2026-10-07: the `preStop` has no channel back but PID 1 itself,
|
|
44
44
|
* so "retired" has to be observable as "PID 1 is gone" — and a pod that has stopped claiming serves
|
|
45
45
|
* nothing it could keep holding for. A SIGTERM landing mid-retire is the ordinary drain: it binds
|
|
46
|
-
* the retire to `drain.deadlineMs` and cuts
|
|
46
|
+
* the retire to the worker's budget — `drain.workerDeadlineMs`, else `drain.deadlineMs` — and cuts
|
|
47
|
+
* off at the margin (`packages/jobs/src/worker.ts`). `isRetiring()` is true from the signal on.
|
|
47
48
|
*/
|
|
48
49
|
export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire {
|
|
49
50
|
if ((options.platform ?? process.platform) === 'win32') return NOTHING;
|
|
@@ -84,6 +85,9 @@ export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire
|
|
|
84
85
|
const listener = (): void => {
|
|
85
86
|
if (retiring) return;
|
|
86
87
|
retiring = true;
|
|
88
|
+
// At the signal, before the worker is awaited or stopped: the app's `isRetiring()` is how its
|
|
89
|
+
// own code (a heartbeat, a status row) sees a retire that `isDraining()` only shows at the end.
|
|
90
|
+
markRetiring();
|
|
87
91
|
// Never rejects: `retire` catches its own failure and always reaches the drain.
|
|
88
92
|
void retire();
|
|
89
93
|
};
|
package/src/site-seo.ts
CHANGED
|
@@ -110,7 +110,7 @@ type Stamp = (file: string) => { readonly lastmod?: string };
|
|
|
110
110
|
/**
|
|
111
111
|
* The app's `seo.sitemap.extra` paths as records, each resolved to the route that answers it — the
|
|
112
112
|
* most specific match, the router's own tie-break. REFUSED, never skipped, when it names no route,
|
|
113
|
-
*
|
|
113
|
+
* a gated one or a `site/` page (listed already): a sitemap missing a page its
|
|
114
114
|
* author listed is the silent drop, and a gated page in it is a URL a crawler cannot open.
|
|
115
115
|
*/
|
|
116
116
|
function extraRoutes(extra: readonly string[], stamp: Stamp): readonly RouteRecord[] {
|
|
@@ -121,9 +121,6 @@ function extraRoutes(extra: readonly string[], stamp: Stamp): readonly RouteReco
|
|
|
121
121
|
.sort((a, b) => b.pattern.specificity - a.pattern.specificity)[0];
|
|
122
122
|
if (entry === undefined)
|
|
123
123
|
throw new SitemapExtraInvalidError(path, 'no registered route answers');
|
|
124
|
-
if (entry.surface === 'api') {
|
|
125
|
-
throw new SitemapExtraInvalidError(path, `is the api/ route ${entry.file}, not a page`);
|
|
126
|
-
}
|
|
127
124
|
if (entry.surface === 'site') {
|
|
128
125
|
throw new SitemapExtraInvalidError(
|
|
129
126
|
path,
|