@ultimat3/cli 23.0.0 → 25.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -15,17 +15,14 @@ export const JOBS_SUBCOMMANDS = [
15
15
  'drain',
16
16
  ] as const;
17
17
 
18
- /**
19
- * The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
20
- * Closed, and read three ways: the flag summary, the refusal, and the `memory` case below.
21
- */
22
- export const DRAIN_TARGETS = ['redis', 'nats'] as const;
23
-
24
18
  export const jobsSpec: CommandSpec = {
25
19
  name: 'jobs',
26
- summary: 'list, show, retry, cancel, remove and promote jobs; pause and resume a queue; drain',
20
+ // Never ENDING in "(planned)": that suffix is how `x help` and the planned-table tests tell a
21
+ // planned COMMAND from a shipped one, and `x jobs` ships — only its `drain` does not.
22
+ summary:
23
+ 'list, show, retry, cancel, remove and promote jobs; pause and resume a queue (drain is planned)',
27
24
  usage:
28
- 'x jobs [ls|show <id>|retry <id>|cancel <id>|rm <id>|promote <id>|pause <queue>|resume <queue>|drain --to <driver>] [--queue q] [--state s] [--name n] [--limit n] [--after cursor] [--from-step name] [--reason text] [--to driver] [--dry-run] [--json]',
25
+ 'x jobs [ls|show <id>|retry <id>|cancel <id>|rm <id>|promote <id>|pause <queue>|resume <queue>|drain] [--queue q] [--state s] [--name n] [--limit n] [--after cursor] [--from-step name] [--reason text] [--json]',
29
26
  requiresApp: true,
30
27
  subcommands: JOBS_SUBCOMMANDS,
31
28
  // The bare `x jobs` lists; it never retries, cancels or drains anything.
@@ -41,8 +38,8 @@ export const jobsSpec: CommandSpec = {
41
38
  summary: 'ls: the next page — the cursor the previous page printed',
42
39
  subcommands: ['ls'],
43
40
  },
44
- // Each of these is read by ONE subcommand — `retryJob`, `cancelJob`, `runDrain` — and says
45
- // so in its own summary. The scope is what makes the parser refuse it anywhere else instead
41
+ // Each of these is read by ONE subcommand — `retryJob`, `cancelJob` — and says so in its own
42
+ // summary. The scope is what makes the parser refuse it anywhere else instead
46
43
  // of accepting it and ignoring it: `x db gen --dry-run` parsed and wrote the migration.
47
44
  {
48
45
  name: 'from-step',
@@ -56,17 +53,5 @@ export const jobsSpec: CommandSpec = {
56
53
  summary: 'cancel: why, recorded on the job',
57
54
  subcommands: ['cancel'],
58
55
  },
59
- {
60
- name: 'to',
61
- type: 'string',
62
- summary: `drain: target driver — ${DRAIN_TARGETS.join(', ')}`,
63
- subcommands: ['drain'],
64
- },
65
- {
66
- name: 'dry-run',
67
- type: 'boolean',
68
- summary: 'drain: report the plan, move nothing',
69
- subcommands: ['drain'],
70
- },
71
56
  ],
72
57
  };
package/src/cmd-jobs.ts CHANGED
@@ -1,6 +1,6 @@
1
- // `x jobs ls|show|retry|cancel|rm|promote|pause|resume|drain` — introspect and recover the job queue, bound to
2
- // `@ultimat3/jobs`'s
3
- // own introspection so the CLI, `/_x` and MCP report identically. This file is CLI wiring only:
1
+ // `x jobs ls|show|retry|cancel|rm|promote|pause|resume` — introspect and recover the job queue,
2
+ // bound to `@ultimat3/jobs`'s own introspection so the CLI, `/_x` and MCP report identically;
3
+ // `drain` is planned (`cmd-planned.ts`): Postgres is the one durable driver. CLI wiring only:
4
4
  // the driver-injected logic is `jobs-report.ts`, the `--json` shapes `jobs-json.ts`, the table
5
5
  // `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
6
6
 
@@ -8,10 +8,6 @@ import { renderFixShellArg } from '@ultimat3/core';
8
8
  import type { JobDriver } from '@ultimat3/jobs';
9
9
  import {
10
10
  cancelJob,
11
- createNatsDriver,
12
- createRedisDriver,
13
- DEFAULT_JOB_PAGE,
14
- jobCursor,
15
11
  MAX_JOB_PAGE,
16
12
  pauseQueue,
17
13
  promoteJob,
@@ -20,18 +16,15 @@ import {
20
16
  } from '@ultimat3/jobs';
21
17
  import { loadApp } from './app-load';
22
18
  import { requireAppRoot } from './app-root';
23
- import { DRAIN_TARGETS, jobsSpec } from './cmd-jobs-spec';
19
+ import { jobsSpec } from './cmd-jobs-spec';
20
+ import { plannedSubcommand } from './cmd-planned';
24
21
  import type { CliCommand, CommandContext } from './command';
25
22
  import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
26
- import type { DrainOutcome } from './jobs-drain';
27
- import { drainJobs } from './jobs-drain';
28
23
  import { withJobDriver } from './jobs-driver';
29
24
  import {
30
25
  backfillToJson,
31
26
  deadLetterToJson,
32
27
  depthToJson,
33
- drainFailureToJson,
34
- drainSkipToJson,
35
28
  jobRecordToJson,
36
29
  jobTraceToJson,
37
30
  pausedToJson,
@@ -41,32 +34,22 @@ import { listJobs, retryJob, showJob } from './jobs-report';
41
34
  import { renderJobTable } from './jobs-table';
42
35
  import { msg } from './messages';
43
36
  import type { CommandResult } from './output';
44
- import { flagBool, flagString } from './parse';
37
+ import { flagString } from './parse';
45
38
 
46
- export { DRAIN_TARGETS, JOBS_SUBCOMMANDS } from './cmd-jobs-spec';
39
+ export { JOBS_SUBCOMMANDS } from './cmd-jobs-spec';
47
40
 
48
41
  /**
49
- * `memory` was on that list until 2026-09 and could not be: `createMemoryDriver()` is a `Map` in
50
- * THIS process, so `x jobs drain --to memory` enqueued each job into it and then `ack`ed the
51
- * durable row off the source. Reproduced against two real drivers — source ready 1 -> 0, target
52
- * ready 1, `ok: true` — and the target dies with the command. `wiki/CLI-Reference.md` said the
53
- * crash window "duplicates a job … instead of losing it"; this target lost every one of them.
54
- *
55
- * Refused by NAME rather than folded into the unknown-value message, for `cmd-deploy.ts`'s
56
- * `readMethod` reason: `--to memory` is a spelling that used to work, so a reader who types it is
57
- * owed the fact that it moved work into a process that is about to exit, not a list of words.
42
+ * A uuid in any spelling Postgres' `uuid` input accepts: canonical, upper-case, unhyphenated,
43
+ * braced. Every driver mints its ids with core's `uuid()`, and `x_jobs.id` is a `uuid` column.
58
44
  */
59
- function refuseMemoryTarget(): never {
60
- throw new BadFlagError({
61
- flag: 'to',
62
- command: 'jobs',
63
- reason:
64
- 'memory is a Map inside this process — the drain would ack every durable row and lose the copy when the command exits',
65
- fix: 'x jobs drain --to redis --json # or --to nats; --dry-run reports the plan and moves nothing',
66
- });
67
- }
45
+ const JOB_ID = /^\{?[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}\}?$/i;
68
46
 
69
- function requireIdPositional(ctx: CommandContext, sub: string): string {
47
+ /**
48
+ * The id, parsed at the door — admin's `readableId` rule: an id no driver could hold is a job that
49
+ * does not exist, answered before a statement is sent. `x jobs show nosuch` reached Postgres and
50
+ * came back a raw `X_DB_STATEMENT_FAILED [22P02]` whose fix was a psql session.
51
+ */
52
+ function requireIdPositional(ctx: CommandContext, sub: string, driver: JobDriver): string {
70
53
  const id = ctx.args.positionals[0];
71
54
  if (id === undefined) {
72
55
  // `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
@@ -76,6 +59,7 @@ function requireIdPositional(ctx: CommandContext, sub: string): string {
76
59
  example: 'x jobs ls --json',
77
60
  });
78
61
  }
62
+ if (!JOB_ID.test(id)) throw new JobUnknownError({ id, driver: driver.name });
79
63
  return id;
80
64
  }
81
65
 
@@ -102,42 +86,10 @@ function refuseOversizedPage(limit: string | undefined): void {
102
86
  command: 'jobs',
103
87
  reason: `one page holds at most ${MAX_JOB_PAGE} jobs`,
104
88
  // The bound is the queue's own constant, screened like any value spliced into a command.
105
- fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.next as --after for the page that follows`,
89
+ fix: `x jobs ls --limit ${renderFixShellArg(String(MAX_JOB_PAGE), '200')} --json # then pass its data.nextCursor as --after while data.hasMore is true`,
106
90
  });
107
91
  }
108
92
 
109
- function requireEnvUrl(env: CommandContext['env'], name: string, target: string): string {
110
- const value = env[name];
111
- if (value === undefined || value.trim().length === 0) {
112
- throw new BadFlagError({
113
- flag: 'to',
114
- command: 'jobs',
115
- reason: `--to ${target} needs ${name} set in the environment`,
116
- });
117
- }
118
- return value;
119
- }
120
-
121
- /**
122
- * `redis`/`nats` are honest `X_NOT_IMPLEMENTED` stubs in `@ultimat3/jobs` — building one here is
123
- * fine even though every `enqueue` on it will fail; `drainJobs` reports that per record. What is
124
- * NOT fine is a target that accepts every enqueue and then vanishes, which is why `memory` is
125
- * refused first and by name rather than falling into the closed-set message below.
126
- * Exported so a test can drive the `--to`/env-var validation without a driver or a boot.
127
- */
128
- export function buildDrainTarget(to: string | undefined, env: CommandContext['env']): JobDriver {
129
- if (to === 'memory') refuseMemoryTarget();
130
- if (to === undefined || !(DRAIN_TARGETS as readonly string[]).includes(to)) {
131
- throw new BadFlagError({
132
- flag: 'to',
133
- command: 'jobs',
134
- reason: `expects one of: ${DRAIN_TARGETS.join(', ')}`,
135
- });
136
- }
137
- if (to === 'redis') return createRedisDriver({ url: requireEnvUrl(env, 'REDIS_URL', 'redis') });
138
- return createNatsDriver({ servers: [requireEnvUrl(env, 'NATS_URL', 'nats')] });
139
- }
140
-
141
93
  async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
142
94
  const limit = flagString(ctx.args, 'limit');
143
95
  refuseOversizedPage(limit);
@@ -148,11 +100,9 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
148
100
  limit,
149
101
  after: flagString(ctx.args, 'after'),
150
102
  });
151
- // A FULL page may have another behind it: `next` is the cursor that asks for it, and `null`
152
- // says this was the last one — so a walk never has to guess from a row count.
153
- const size = limit === undefined ? DEFAULT_JOB_PAGE : Number(limit);
154
- const last = result.rows[result.rows.length - 1];
155
- const next = last === undefined || result.rows.length < size ? null : jobCursor(last);
103
+ // `nextCursor` exists exactly when a row past this page does (`listJobs` asks the queue), so a
104
+ // full last page prints no "next page" a caller would follow to an empty one.
105
+ const next = result.nextCursor;
156
106
  const [paused, workers] = await Promise.all([
157
107
  driver.introspect?.pausedQueues() ?? [],
158
108
  driver.introspect?.workers() ?? [],
@@ -203,7 +153,8 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
203
153
  rows: result.rows.map(jobRecordToJson),
204
154
  deadLetters: result.deadLetters.map(deadLetterToJson),
205
155
  backfills: result.backfills.map(backfillToJson),
206
- next,
156
+ nextCursor: result.nextCursor,
157
+ hasMore: result.hasMore,
207
158
  pausedQueues: paused.map(pausedToJson),
208
159
  workers: workers.map(workerToJson),
209
160
  },
@@ -211,7 +162,7 @@ async function runLs(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
211
162
  }
212
163
 
213
164
  async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
214
- const trace = await showJob(driver, requireIdPositional(ctx, 'show'));
165
+ const trace = await showJob(driver, requireIdPositional(ctx, 'show', driver));
215
166
  return {
216
167
  ok: true,
217
168
  command: 'jobs',
@@ -226,7 +177,7 @@ async function runShow(driver: JobDriver, ctx: CommandContext): Promise<CommandR
226
177
  }
227
178
 
228
179
  async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
229
- const id = requireIdPositional(ctx, 'retry');
180
+ const id = requireIdPositional(ctx, 'retry', driver);
230
181
  const trace = await retryJob(driver, id, flagString(ctx.args, 'from-step'));
231
182
  return {
232
183
  ok: true,
@@ -243,7 +194,7 @@ async function runRetry(driver: JobDriver, ctx: CommandContext): Promise<Command
243
194
  * The trace is rendered by the same projection `show` and `retry` use — one shape for one job.
244
195
  */
245
196
  async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
246
- const id = requireIdPositional(ctx, 'cancel');
197
+ const id = requireIdPositional(ctx, 'cancel', driver);
247
198
  const trace = await cancelJob(driver, id, flagString(ctx.args, 'reason'));
248
199
  // `cancelJob` re-reads the job after cancelling, so `undefined` would mean the row vanished
249
200
  // between the two — reported as the same refusal rather than rendered as a success with no job.
@@ -258,7 +209,7 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
258
209
 
259
210
  /** Exit 0 means the row is gone. An id nobody queued is `X_JOB_UNKNOWN`; a running one refuses. */
260
211
  async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
261
- const id = requireIdPositional(ctx, 'rm');
212
+ const id = requireIdPositional(ctx, 'rm', driver);
262
213
  const removed = await removeJob(driver, id);
263
214
  if (removed === undefined) throw new JobUnknownError({ id, driver: driver.name });
264
215
  return {
@@ -270,7 +221,7 @@ async function runRm(driver: JobDriver, ctx: CommandContext): Promise<CommandRes
270
221
  }
271
222
 
272
223
  async function runPromote(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
273
- const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote'));
224
+ const promoted = await promoteJob(driver, requireIdPositional(ctx, 'promote', driver));
274
225
  return {
275
226
  ok: true,
276
227
  command: 'jobs',
@@ -298,59 +249,6 @@ async function runPause(
298
249
  };
299
250
  }
300
251
 
301
- /**
302
- * A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
303
- * design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
304
- * empty a driver, and a partial move that exited 0 would read as "the queue is clear".
305
- *
306
- * Exported, and separate from the flag reading above it, because every target the flag now accepts
307
- * needs a server: a test can produce a real outcome from two drivers and render THAT, where
308
- * driving the whole command would need a redis or a nats to move anything at all.
309
- */
310
- export function drainResult(outcome: DrainOutcome): CommandResult {
311
- const dryRun = outcome.dryRun;
312
- const findings = outcome.failures.map((failure) => failure.finding);
313
- const lines: string[] = [];
314
- if (outcome.skipped.length > 0) {
315
- const count = outcome.skipped.length;
316
- lines.push(` ${msg('cli.jobs.skipped', { count, from: outcome.from })}`);
317
- for (const skip of outcome.skipped) {
318
- lines.push(` ${skip.id} ${skip.name} (${skip.queue}) ${skip.state} ${skip.reason}`);
319
- }
320
- }
321
- const partial = outcome.skipped.length > 0;
322
- return {
323
- ok: findings.length === 0 && !partial,
324
- command: 'jobs',
325
- summary: msg(partial ? 'cli.jobs.drainedPartial' : 'cli.jobs.drained', {
326
- count: dryRun ? outcome.candidates.length : outcome.moved.length,
327
- from: outcome.from,
328
- to: outcome.to,
329
- skipped: outcome.skipped.length,
330
- }),
331
- lines,
332
- findings,
333
- data: {
334
- from: outcome.from,
335
- to: outcome.to,
336
- dryRun: outcome.dryRun,
337
- candidates: outcome.candidates.map(jobRecordToJson),
338
- moved: outcome.moved.map(jobRecordToJson),
339
- skipped: outcome.skipped.map(drainSkipToJson),
340
- failures: outcome.failures.map(drainFailureToJson),
341
- },
342
- };
343
- }
344
-
345
- /** The move, then the render. The target is built ABOVE `withJobDriver` — see `run` below. */
346
- async function runDrain(
347
- driver: JobDriver,
348
- target: JobDriver,
349
- ctx: CommandContext,
350
- ): Promise<CommandResult> {
351
- return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
352
- }
353
-
354
252
  /** The subcommands that answer a `JobTrace`. `ls`, `rm`, `promote` and the rest read rows only. */
355
253
  const TRACE_SUBCOMMANDS: ReadonlySet<string> = new Set(['show', 'retry', 'cancel']);
356
254
 
@@ -359,13 +257,11 @@ export const jobsCommand: CliCommand = {
359
257
  async run(ctx: CommandContext): Promise<CommandResult> {
360
258
  const root = requireAppRoot('jobs', ctx.cwd).dir;
361
259
  const sub = ctx.args.subcommand ?? 'ls';
362
- // BEFORE `withJobDriver`, which boots the SOURCE queue and pings it. `--to` is a flag, so
363
- // whether it names a durable driver is answerable with no server at all — and reading it
364
- // inside meant `x jobs drain --to memory` on a box whose database is down reported the boot
365
- // failure instead of `X_CLI_BAD_FLAG`, i.e. the operator repaired Postgres to be told the
366
- // word they typed was refused by name. It also opens a connection to a target the command
367
- // then refuses, which is a socket nothing closes.
368
- const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
260
+ // BEFORE `withJobDriver`, which boots the source queue: the answer needs no server, so a box
261
+ // whose database is down gets it rather than the boot failure of a queue never to be used —
262
+ // and nothing is leased. 24.x's `--to`/`--dry-run` are deleted, so the parser refuses them
263
+ // (`X_CLI_BAD_FLAG`); only a bare `x jobs drain` reaches this answer.
264
+ if (sub === 'drain') throw plannedSubcommand('jobs', 'drain');
369
265
  // A TRACE is the queue row projected through the job's own declaration — its concurrency key,
370
266
  // its retry schedule — and a declaration exists in this process only once the app is loaded.
371
267
  // Unloaded, `show` answered `concurrencyKey: null` and `retryDelaysMs: []` for every job of
@@ -379,13 +275,10 @@ export const jobsCommand: CliCommand = {
379
275
  if (sub === 'rm') return runRm(driver, ctx);
380
276
  if (sub === 'promote') return runPromote(driver, ctx);
381
277
  if (sub === 'pause' || sub === 'resume') return runPause(driver, ctx, sub);
382
- if (target !== null) return runDrain(driver, target, ctx);
383
278
  return runLs(driver, ctx);
384
279
  });
385
280
  },
386
281
  };
387
282
 
388
- export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
389
- export { drainJobs } from './jobs-drain';
390
283
  export type { JobsListFilter, JobsListResult } from './jobs-report';
391
284
  export { listJobs, retryJob, showJob } from './jobs-report';
@@ -6,7 +6,7 @@ import type { CommandSpec } from './parse';
6
6
  export const manifestSpec: CommandSpec = {
7
7
  name: 'manifest',
8
8
  summary: 'regenerate x.manifest.json and openapi.json from the code',
9
- usage: 'x manifest [--check] [--json]',
9
+ usage: 'x manifest [--check] [--no-openapi] [--json]',
10
10
  requiresApp: true,
11
11
  flags: [
12
12
  { name: 'check', type: 'boolean', summary: 'fail if the committed files are stale' },
@@ -4,16 +4,15 @@
4
4
 
5
5
  import { join } from 'node:path';
6
6
  import type { Manifest } from '@ultimat3/manifest';
7
- import { assertNoDrift, MANIFEST_FILENAME } from '@ultimat3/manifest';
7
+ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
8
8
  import { writeAppArtifacts } from './app-artifacts';
9
9
  import { appManifest } from './app-manifest';
10
- import { openApiStaleness } from './app-openapi';
11
10
  import { requireAppRoot } from './app-root';
12
11
  import { manifestSpec } from './cmd-manifest-spec';
13
12
  import type { CliCommand, CommandContext } from './command';
13
+ import { manifestStaleness } from './manifest-staleness';
14
14
  import { msg } from './messages';
15
- import type { CommandResult, Finding, JsonValue } from './output';
16
- import { findingFrom } from './output';
15
+ import type { CommandResult, JsonValue } from './output';
17
16
  import { flagBool } from './parse';
18
17
 
19
18
  const countsOf = (manifest: Manifest): JsonValue => ({
@@ -27,21 +26,6 @@ const countsOf = (manifest: Manifest): JsonValue => ({
27
26
  policies: manifest.policies.length,
28
27
  });
29
28
 
30
- /**
31
- * The identical comparison `x verify`'s `manifest` step makes, through the identical function.
32
- * A buildId equality test here would answer "fresh" for a hand-edited file whose body no longer
33
- * hashes to the id it carries — and two commands giving two answers about one file is itself the
34
- * drift the manifest exists to prevent.
35
- */
36
- async function staleness(root: string, manifest: Manifest): Promise<Finding | undefined> {
37
- try {
38
- await assertNoDrift({ manifest, path: join(root, MANIFEST_FILENAME) });
39
- return undefined;
40
- } catch (error) {
41
- return { ...findingFrom(error), at: MANIFEST_FILENAME };
42
- }
43
- }
44
-
45
29
  export const manifestCommand: CliCommand = {
46
30
  spec: manifestSpec,
47
31
  async run(ctx: CommandContext): Promise<CommandResult> {
@@ -50,12 +34,9 @@ export const manifestCommand: CliCommand = {
50
34
  const counts = countsOf(manifest);
51
35
 
52
36
  if (flagBool(ctx.args, 'check')) {
53
- // BOTH files the command writes: `--check` compared only `x.manifest.json`, so a stale
54
- // `openapi.json` — the one the typed client is generated from — read as fresh.
55
- const stale = [
56
- ...[await staleness(root, manifest)].filter((one) => one !== undefined),
57
- ...(await openApiStaleness(root, manifest)),
58
- ];
37
+ // The gate's own check, through its function (`manifest-staleness.ts`): both files this
38
+ // command writes, and the same code for each condition `x verify`'s `manifest` step gives.
39
+ const stale = await manifestStaleness(root, manifest);
59
40
  return {
60
41
  ok: stale.length === 0 && findings.length === 0,
61
42
  command: 'manifest',
package/src/cmd-mcp.ts CHANGED
@@ -3,17 +3,24 @@
3
3
  // supplies only the app, the caller and the socket. A tool answered here would be a second answer
4
4
  // to a question the framework already answers.
5
5
 
6
- import { markListening, nanoid, timingSafeEqual } from '@ultimat3/core';
7
- import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
6
+ import {
7
+ markListening,
8
+ nanoid,
9
+ renderFixShellArg,
10
+ timingSafeEqual,
11
+ UltimateError,
12
+ } from '@ultimat3/core';
13
+ import { mcpHttpRoute, type StdioTransportInput, serveStdio } from '@ultimat3/mcp';
8
14
  import { requireAppRoot } from './app-root';
9
15
  import { DEFAULT_PORT, mcpSpec } from './cmd-mcp-spec';
10
16
  import type { CliCommand, CommandContext } from './command';
11
17
  import { BadFlagError } from './errors';
12
- import { intFlagOr, PORT_RANGE } from './flag-number';
18
+ import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
13
19
  import { holdUntilShutdown } from './hold';
14
20
  import type { CliMcpServer } from './mcp-host';
15
21
  import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
16
22
  import { msg } from './messages';
23
+ import { isAddressInUse } from './metrics-endpoint';
17
24
  import type { CommandResult } from './output';
18
25
  import { flagString } from './parse';
19
26
 
@@ -30,8 +37,12 @@ const isTransport = (value: string): value is Transport =>
30
37
 
31
38
  /** The catalog, from the server's own registry — never a list kept here. */
32
39
  async function catalog(host: CliMcpServer): Promise<CommandResult> {
33
- const tools = host.server.list(host.caller);
34
- await host.close();
40
+ let tools: ReturnType<CliMcpServer['server']['list']>;
41
+ try {
42
+ tools = host.server.list(host.caller);
43
+ } finally {
44
+ await host.close();
45
+ }
35
46
  return {
36
47
  ok: true,
37
48
  command: 'mcp',
@@ -56,6 +67,21 @@ const notFound = (path: string): Response =>
56
67
  { status: 404, headers: { 'content-type': 'application/json' } },
57
68
  );
58
69
 
70
+ /**
71
+ * The MCP port is bound already — by another `x mcp serve`, or by the inspector, whose default
72
+ * 9229 this one shares. `metrics-endpoint.ts`'s pattern: an `EADDRINUSE` throw becomes the
73
+ * framework's `X_PORT_IN_USE`, with the invocation one port over.
74
+ */
75
+ export class McpPortInUseError extends UltimateError {
76
+ constructor(input: { port: number }) {
77
+ super({
78
+ code: 'X_PORT_IN_USE',
79
+ cause: `port ${String(input.port)} is already bound, so x mcp serve could not open its HTTP transport`,
80
+ fix: `x mcp serve --transport http --port ${renderFixShellArg(String(neighbouringPort(input.port)), '9230')}`,
81
+ });
82
+ }
83
+ }
84
+
59
85
  /** A running HTTP transport. `stop()` releases the socket AND the host's lazily booted services. */
60
86
  export interface McpHttpServer {
61
87
  readonly result: CommandResult;
@@ -70,7 +96,7 @@ export interface McpHttpServer {
70
96
  * directory outlive `run()` otherwise, and nothing — a test, an embedding caller, or a signal —
71
97
  * could ever release them.
72
98
  */
73
- export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
99
+ export async function startMcpHttp(host: CliMcpServer, port: number): Promise<McpHttpServer> {
74
100
  const token = nanoid(32);
75
101
  const route = mcpHttpRoute({
76
102
  server: host.server,
@@ -83,17 +109,26 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
83
109
  ? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES }
84
110
  : null,
85
111
  });
86
- const handle = Bun.serve({
87
- port,
88
- hostname: 'localhost',
89
- fetch: (request: Request): Response | Promise<Response> => {
90
- const url = new URL(request.url);
91
- if (request.method !== route.method || url.pathname !== route.path) {
92
- return notFound(route.path);
93
- }
94
- return route.handle(request);
95
- },
96
- });
112
+ let handle: ReturnType<typeof Bun.serve>;
113
+ try {
114
+ handle = Bun.serve({
115
+ port,
116
+ hostname: 'localhost',
117
+ fetch: (request: Request, server): Response | Promise<Response> => {
118
+ const url = new URL(request.url);
119
+ if (request.method !== route.method || url.pathname !== route.path) {
120
+ return notFound(route.path);
121
+ }
122
+ // The socket's peer: failed tokens are metered per address, and only the server knows it.
123
+ return route.handle(request, { address: server.requestIP(request)?.address });
124
+ },
125
+ });
126
+ } catch (error) {
127
+ // The host booted before the bind; a refusal that left it open would leak its database.
128
+ await host.close();
129
+ if (isAddressInUse(error)) throw new McpPortInUseError({ port });
130
+ throw error;
131
+ }
97
132
  // Announces the socket as this process's own, so a caller on it is never mistaken for egress.
98
133
  const stopListening = markListening(handle.url.origin);
99
134
  const url = `${handle.url.origin}${route.path}`;
@@ -131,10 +166,24 @@ export const stdioResult = (tools: number): CommandResult => ({
131
166
  stream: 'stderr',
132
167
  });
133
168
 
134
- /** stdout is the WIRE: `serveStdio` owns fd 1 for as long as the peer holds stdin open. */
135
- async function serveOverStdio(host: CliMcpServer): Promise<CommandResult> {
136
- await serveStdio({ server: host.server, caller: host.caller });
137
- await host.close();
169
+ /** The pipe a session reads, when it is not this process's own stdin — a test's script. */
170
+ export type StdioPipe = Pick<StdioTransportInput, 'input' | 'write'>;
171
+
172
+ /**
173
+ * stdout is the WIRE: `serveStdio` owns fd 1 for as long as the peer holds stdin open. Exported
174
+ * with `pipe` so a test can end the session with a throw instead of a closed stdin.
175
+ */
176
+ export async function serveOverStdio(
177
+ host: CliMcpServer,
178
+ pipe: StdioPipe = {},
179
+ ): Promise<CommandResult> {
180
+ // `finally`, as `startMcpHttp`'s refused bind does: a session that throws must not leave the
181
+ // host's PGlite data directory held by a process about to report the failure.
182
+ try {
183
+ await serveStdio({ server: host.server, caller: host.caller, ...pipe });
184
+ } finally {
185
+ await host.close();
186
+ }
138
187
  return stdioResult(host.tools.length);
139
188
  }
140
189
 
@@ -179,7 +228,7 @@ export const mcpCommand: CliCommand = {
179
228
  // starts is what releases the socket and the database. Registering a shutdown hook and
180
229
  // returning was the older shape — nothing installed a signal handler, so the hook was never
181
230
  // reached and the exit code closed the socket the line above had just announced.
182
- const served = startMcpHttp(host, port);
231
+ const served = await startMcpHttp(host, port);
183
232
  return { ...served.result, hold: holdUntilShutdown('mcp', () => served.stop()) };
184
233
  },
185
234
  };