@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.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/package.json +28 -28
  3. package/src/cmd-db-branch.ts +15 -11
  4. package/src/cmd-db-spec.ts +2 -2
  5. package/src/cmd-deploy-drain.ts +6 -1
  6. package/src/cmd-docs.ts +1 -1
  7. package/src/cmd-jobs-spec.ts +11 -7
  8. package/src/cmd-jobs.ts +10 -10
  9. package/src/cmd-planned.ts +3 -3
  10. package/src/cmd-registries-spec.ts +6 -6
  11. package/src/cmd-registries.ts +6 -6
  12. package/src/cmd-routes-spec.ts +1 -1
  13. package/src/cmd-routes.ts +9 -52
  14. package/src/cmd-tasks.ts +2 -2
  15. package/src/db-branch.ts +6 -6
  16. package/src/db-finding.ts +1 -1
  17. package/src/dev-restart-retire.ts +3 -1
  18. package/src/doctor-guards.ts +74 -19
  19. package/src/document-styles.ts +2 -4
  20. package/src/error-contract.ts +1 -1
  21. package/src/errors.ts +4 -4
  22. package/src/fix-command.ts +1 -1
  23. package/src/index.ts +6 -6
  24. package/src/jobs-report.ts +3 -3
  25. package/src/jobs-table.ts +1 -1
  26. package/src/live-routes.ts +0 -1
  27. package/src/mcp-db-target.ts +1 -1
  28. package/src/mcp-errors.ts +2 -2
  29. package/src/mcp-host.ts +4 -2
  30. package/src/messages.ts +4 -2
  31. package/src/parse.ts +23 -2
  32. package/src/role-replicator.ts +1 -1
  33. package/src/route-table.ts +113 -0
  34. package/src/runtime-jobs.ts +1 -1
  35. package/src/serve-boot.ts +8 -2
  36. package/src/serve-drain.ts +5 -1
  37. package/src/serve-retire.ts +7 -3
  38. package/src/site-seo.ts +1 -4
  39. package/src/templates/scaffold-claude-agents.ts +4 -4
  40. package/src/templates/scaffold-claude-commands.ts +2 -2
  41. package/src/templates/scaffold-claude.ts +1 -1
  42. package/src/templates/scaffold-guards.ts +12 -0
  43. package/src/templates/scaffold-helm-templates.ts +6 -2
  44. 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();
@@ -1,12 +1,14 @@
1
- // `x doctor`'s guard listing: the guards the framework ships that this app's `guards/` does not
2
- // hold, each with the command that adds it. A LISTING, never a finding: every shipped guard says
3
- // "delete this file to drop the rule", so an app without one may have decided — and a red
4
- // `x doctor` over a decision would be the framework overruling the convention it handed over.
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 MissingGuard[]> {
28
- return missingShippedGuards(await guardPaths(root));
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
- missing: readonly MissingGuard[],
81
+ entries: readonly GuardListingEntry[],
38
82
  ): CommandResult {
39
83
  const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
40
- const listed: JsonValue = missing.map(({ name, add }) => ({ name, add }));
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: { missing: listed } },
44
- ...(missing.length === 0
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
  }
@@ -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. `api/` is excluded because
34
- * it emits no document at all — a surface with nothing to style is not a surface missing its
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);
@@ -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 describe posts.publish --json" names the observation
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 describe <name>` named a declaration the registries do not hold —
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. `describe` for the registries, `show` for `x tasks`. */
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 ?? 'describe'} ${input.suggestion}`,
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 ls --json',
282
+ fix: 'x jobs list --json',
283
283
  });
284
284
  }
285
285
  }
@@ -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 drop <name>`), where a placeholder is exactly right.
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';
@@ -1,4 +1,4 @@
1
- // Pure, driver-injected job operations behind `x jobs`: flag parsing, plus ls / show / retry. No
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
- // ── ls ────────────────────────────────────────────────────────────────────
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 ls` is a LIVE view of the queue — a pass that
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 ls` terminal table, and nothing else. Split out of `jobs-report.ts` so that
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
 
@@ -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;
@@ -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 ls` about what a branch is would let `db.migrate` run against a shared
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 ls --json',
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 ls --json',
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: () => describePages(),
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 ls` column — a value, not a column key. */
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 ls --after {cursor}',
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 ls` that ignores the id.
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.
@@ -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 describe` are keyed by — and `entity('user',
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
+ }
@@ -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 ls --json — every queue listed with no worker needs one: WORKER_QUEUES=<queue>,<queue> on another worker Deployment, or add it to this one's`,
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 budget = drain?.deadlineMs === undefined ? {} : { deadlineMs: drain.deadlineMs };
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
@@ -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
- : { readinessGraceMs: drain.readinessGraceMs, deadlineMs: drain.deadlineMs };
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). */
@@ -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 `drain.deadlineMs`. Framework-owned: no port, no app code.
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 off at the margin (`packages/jobs/src/worker.ts`).
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
- * an `api/` route, a gated one or a `site/` page (listed already): a sitemap missing a page its
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,