@ultimat3/cli 8.0.0 → 10.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 (64) hide show
  1. package/CLAUDE.md +9 -3
  2. package/package.json +26 -25
  3. package/src/affected.ts +0 -3
  4. package/src/app-boundaries.ts +4 -5
  5. package/src/app-env.ts +7 -2
  6. package/src/app-load.ts +7 -0
  7. package/src/browser-launcher.ts +0 -2
  8. package/src/budgets.ts +4 -3
  9. package/src/cmd-build.ts +2 -2
  10. package/src/cmd-db-branch.ts +2 -2
  11. package/src/cmd-deploy.ts +20 -6
  12. package/src/cmd-docs.ts +2 -1
  13. package/src/cmd-doctor.ts +3 -5
  14. package/src/cmd-env.ts +2 -2
  15. package/src/cmd-fix.ts +2 -4
  16. package/src/cmd-new.ts +2 -2
  17. package/src/cmd-shot.ts +22 -2
  18. package/src/db-finding.ts +2 -2
  19. package/src/db-seed.ts +0 -3
  20. package/src/dev-assets.ts +4 -7
  21. package/src/dev-cache.ts +140 -36
  22. package/src/dev-lock.ts +8 -7
  23. package/src/dev-purge.ts +120 -0
  24. package/src/dev-queue.ts +31 -6
  25. package/src/dev-render.ts +11 -14
  26. package/src/dev-runtime.ts +62 -15
  27. package/src/dev-storage.ts +8 -2
  28. package/src/dev-sync.ts +37 -2
  29. package/src/document-styles.ts +4 -2
  30. package/src/drift.ts +3 -2
  31. package/src/error-codes.ts +9 -2
  32. package/src/error-contract.ts +5 -5
  33. package/src/errors.ts +1 -29
  34. package/src/flag-reads.ts +2 -2
  35. package/src/generate-write.ts +3 -2
  36. package/src/guards.ts +4 -4
  37. package/src/index.ts +2 -6
  38. package/src/island-bundle.ts +34 -10
  39. package/src/island-routes.ts +7 -1
  40. package/src/island-styles.ts +1 -1
  41. package/src/mcp-errors.ts +2 -2
  42. package/src/metrics-endpoint.ts +0 -2
  43. package/src/output.ts +2 -2
  44. package/src/prerender.ts +34 -20
  45. package/src/runtime-overrides.ts +1 -1
  46. package/src/serve.ts +1 -1
  47. package/src/solid-loader.ts +1 -1
  48. package/src/static-report.ts +41 -3
  49. package/src/style-csp.ts +2 -1
  50. package/src/templates/scaffold-container.ts +12 -0
  51. package/src/templates/scaffold-docs.ts +11 -4
  52. package/src/templates/scaffold-domain-package.ts +3 -1
  53. package/src/templates/scaffold-repo.ts +19 -9
  54. package/src/templates/slice-foundation.ts +3 -5
  55. package/src/test-shards.ts +2 -2
  56. package/src/tsconfig-references.ts +2 -2
  57. package/src/verify-checks.ts +3 -2
  58. package/src/verify-floor.ts +8 -6
  59. package/src/verify-run.ts +2 -2
  60. package/src/verify-step.ts +2 -1
  61. package/src/verify-test-run.ts +2 -2
  62. package/src/workspace-checks.ts +10 -12
  63. package/src/workspace-graph.ts +3 -2
  64. package/src/write-line.ts +7 -1
@@ -4,6 +4,8 @@
4
4
  // boot installs, only backed by embedded drivers.
5
5
 
6
6
  import { mkdirSync } from 'node:fs';
7
+ import { dirname } from 'node:path';
8
+ import { configureAuthLimiters, postgresAuthLimiter, resetAuthLimiters } from '@ultimat3/auth';
7
9
  import type { PurgeDriver } from '@ultimat3/cache';
8
10
  import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
9
11
  import {
@@ -11,6 +13,7 @@ import {
11
13
  registerReadinessCheck,
12
14
  renderThrowable,
13
15
  resolveEnvironment,
16
+ systemClock,
14
17
  } from '@ultimat3/core';
15
18
  import type { RateLimitStore } from '@ultimat3/http';
16
19
  import { postgresRateLimitStore } from '@ultimat3/http';
@@ -27,7 +30,8 @@ import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
27
30
  import { selectTransport } from '@ultimat3/realtime/server';
28
31
  import type { Storage } from '@ultimat3/storage';
29
32
  import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
30
- import { startCacheTiers } from './dev-cache';
33
+ import { loadCacheTiers, startCacheTiers } from './dev-cache';
34
+ import { installRetentionSweep } from './dev-purge';
31
35
  import type { DevDbClient } from './dev-queue';
32
36
  import { pgExecutorFor, startQueue } from './dev-queue';
33
37
  import type { DevServices, Env } from './dev-services';
@@ -182,8 +186,14 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
182
186
  // missing" from a helper the operator never configured. Not an outright ban on the local disk in
183
187
  // production: a single-node Compose deploy on a mounted volume WITH a real secret is a rung on
184
188
  // the scale ladder, and refusing it would be a deploy-shape decision, not a security fix.
185
- if (!isLocal() && usesDevStorageSecret()) {
186
- throw new LocalDiskUnsafeError({ environment: resolveEnvironment(), root });
189
+ // `{ env }` on ALL THREE, never the ambient `process.env`: this function is HANDED the boot's
190
+ // environment and reads `S3_BUCKET` off it one branch above, so a guard asking a second source
191
+ // could answer `development` for a process booting as `production` — or, with
192
+ // `usesDevStorageSecret({ env })` left bare, refuse a boot whose own env carries a real
193
+ // `STORAGE_SIGNING_SECRET` because the PROCESS does not. Which environment, whether a secret
194
+ // exists, and the name the message prints are one question about one table.
195
+ if (!isLocal({ env }) && usesDevStorageSecret({ env })) {
196
+ throw new LocalDiskUnsafeError({ environment: resolveEnvironment({ env }), root });
187
197
  }
188
198
  try {
189
199
  mkdirSync(root, { recursive: true });
@@ -194,7 +204,9 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
194
204
  `mount a writable volume at ${root}, or set S3_ENDPOINT and S3_BUCKET to use object storage instead`,
195
205
  );
196
206
  }
197
- return defineStorage({ disks: { local: localDriver({ root }) }, default: 'local' });
207
+ // The guard three lines up reads `env`; so must the disk it guards. Otherwise the boot's
208
+ // environment decides whether signing is allowed and the process's decides what key is used.
209
+ return defineStorage({ disks: { local: localDriver({ root, env }) }, default: 'local' });
198
210
  }
199
211
 
200
212
  /**
@@ -288,16 +300,45 @@ export async function startServices(
288
300
  const bus: TransportSelection = selectTransport(env);
289
301
  const queue = await startQueue(services, overrides);
290
302
  const { db, jobs, outbox, events } = queue;
303
+ // The same executor the jobs driver, the outbox, the event bus and the idempotency store run
304
+ // on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
305
+ // `undefined`), one wrapper.
306
+ const executor = pgExecutorFor(db);
307
+ const rateLimitStore = postgresRateLimitStore({ executor });
291
308
  // Boot is a sequence of external resources, and every step after the first can reject — the
292
309
  // queue is already up, so from here an unwind must release it exactly like everything after it.
310
+ // Which is why the `try` opens on the NEXT line and not eight steps further down: it did, and
311
+ // the steps above it registered process-wide state (`configureAuthLimiters`, the `purge()` job
312
+ // and its `task`) that throws on a name a previous boot in this process left in the registry —
313
+ // `X_JOB_NAME_TAKEN`, outside the unwind, so `x dev` exited holding the PGlite lock, the pool
314
+ // and the ambient accessors. Nothing may be pushed onto `started` from outside this block.
293
315
  const started: (() => void | Promise<void>)[] = [() => queue.stop()];
294
- // One readiness check per resource this boot OWNS, released with it. Nothing in the tree
295
- // registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
296
- // `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `dev-roles.ts` calls it
297
- // before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
298
- // healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
299
- started.push(probedReadinessCheck('database', () => db.ping()));
300
316
  try {
317
+ // Where failed sign-ins are counted, installed BEFORE `loadApp` for the reason the idempotency
318
+ // store is: `defineAuth` is the app's call and it runs when the app's modules import, so a seam
319
+ // filled afterwards is one every app would have to fill itself. A FACTORY and not a limiter —
320
+ // the app declares `maxAttempts`/`windowMs`/`lockoutMs` and this boot has not read them yet, so
321
+ // a limiter built here would be refused by `assertAuthLimiterPolicy` on any app that tuned one.
322
+ //
323
+ // Until this line every deployment the framework produces counted lockouts per POD, while
324
+ // `x new` scaffolds `replicas: 2` and `docker/helm` runs three — `maxAttempts × N` guesses per
325
+ // account, and a lockout one replica established invisible to the rest.
326
+ configureAuthLimiters((policy) =>
327
+ postgresAuthLimiter({ executor, clock: systemClock, policy }),
328
+ );
329
+ started.push(() => resetAuthLimiters());
330
+ // The hourly sweep over the three framework tables this boot is responsible for. Every one of
331
+ // them ships a `purgeExpired()` that nothing called, so every row written was a row kept —
332
+ // `x_rate_limit` takes one upsert per request the web role serves, assets included.
333
+ started.push(
334
+ installRetentionSweep({ idempotency: queue.idempotency, rateLimit: rateLimitStore }),
335
+ );
336
+ // One readiness check per resource this boot OWNS, released with it. Nothing in the tree
337
+ // registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
338
+ // `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `dev-roles.ts` calls it
339
+ // before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
340
+ // healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
341
+ started.push(probedReadinessCheck('database', () => db.ping()));
301
342
  // Dialled here rather than at selection: an unreachable bus must fail at `x dev`, not on the
302
343
  // first change nobody receives, and the socket is a resource the unwind below has to release.
303
344
  // A supplied transport is already connected and is NOT closed here: whoever built it owns its
@@ -331,7 +372,13 @@ export async function startServices(
331
372
  // recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
332
373
  // this boot is the only thing that registers one, and a tier left behind would purge for a
333
374
  // process that has stopped.
334
- started.push(startCacheTiers({ env, purge, transport }));
375
+ // The ladder is the app's declaration, never the environment's. `cache.tiers` was declared,
376
+ // validated and documented while NOTHING read it — so an app asking for one rung got four,
377
+ // and one asking for a shared tier got whatever `REDIS_URL` happened to say. `.x` is
378
+ // `resolveServices`' own join, so its parent is the app root: the one fact this function needs
379
+ // and does not already carry.
380
+ const tiers = await loadCacheTiers(dirname(services.stateDir));
381
+ started.push(startCacheTiers({ env, purge, transport, tiers }));
335
382
 
336
383
  return {
337
384
  services,
@@ -343,12 +390,12 @@ export async function startServices(
343
390
  storage,
344
391
  mail,
345
392
  mailDetail: selection.detail,
393
+ // Built above, beside the auth limiter factory and the retention sweep that purges its
394
+ // table: three readers of one executor, resolved once. `startWeb` is what an override
395
+ // replaces it at.
396
+ rateLimitStore,
346
397
  // The env key that selected the bus — or the honest answer that no env key did, because a
347
398
  // boot line reading `NATS_URL` over a transport the host handed in is a lie a script parses.
348
- // The same executor the jobs driver, the outbox, the event bus and the idempotency store
349
- // run on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
350
- // `undefined`), one wrapper. `startWeb` is what an override replaces it at.
351
- rateLimitStore: postgresRateLimitStore({ executor: pgExecutorFor(db) }),
352
399
  transportDetail: overrides?.transport === undefined ? bus.detail : 'runtime override',
353
400
  presenceTtlMs: bus.presenceTtlMs,
354
401
  purge,
@@ -151,8 +151,14 @@ export function parseByteRange(
151
151
  if (from === '' && to === '') return undefined;
152
152
  if (from === '') {
153
153
  const wanted = Number(to);
154
- // A suffix longer than the object is the whole object, not a refusal.
155
- return wanted === 0 ? UNSATISFIABLE : { start: Math.max(size - wanted, 0), end: size - 1 };
154
+ // A suffix longer than the object is the whole object, not a refusal — but there is no whole
155
+ // object to fall back to at `size === 0`, and the arithmetic below answers `{ start: 0, end:
156
+ // -1 }`, which the route renders as `content-range: bytes 0--1/0` with status 206. RFC 9110
157
+ // requires 416. The non-suffix branch already gets this right through `start >= size`; this
158
+ // one had no equivalent test.
159
+ return size === 0 || wanted === 0
160
+ ? UNSATISFIABLE
161
+ : { start: Math.max(size - wanted, 0), end: size - 1 };
156
162
  }
157
163
  const start = Number(from);
158
164
  const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
package/src/dev-sync.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Split from `dev-roles.ts` because it is the one role with an authenticator, a presence registry
3
3
  // and a listener of its own — and because that file is the boot's index, not its detail.
4
4
 
5
- import { createContext, logger } from '@ultimat3/core';
5
+ import { createContext, logger, UltimateError } from '@ultimat3/core';
6
6
  import { listQueries } from '@ultimat3/query';
7
7
  import {
8
8
  ChannelHub,
@@ -15,8 +15,43 @@ import {
15
15
  SocketRegistry,
16
16
  } from '@ultimat3/realtime/server';
17
17
  import type { StartRolesOptions } from './dev-roles';
18
+ import { neighbouringPort, PORT_RANGE } from './flag-number';
18
19
  import { syncAuthenticator } from './sync-authenticator';
19
20
 
21
+ /**
22
+ * Beside its one thrower rather than in `errors.ts`, which is at 461 of the 500-line ceiling —
23
+ * the arrangement `db-seed.ts` and `metrics-endpoint.ts` already take. The code is
24
+ * `X_PORT_INVALID`, this package's own: "the port asked for is not one" is what it already means,
25
+ * and a second code for the same fact is the synonym the registry exists to prevent.
26
+ */
27
+ class SyncPortUnavailableError extends UltimateError {
28
+ constructor(input: { port: number }) {
29
+ super({
30
+ code: 'X_PORT_INVALID',
31
+ cause: `the sync role binds PORT + 1, and PORT=${input.port} is the top of the range — it would ask for ${input.port + 1}, which is not a TCP port`,
32
+ fix: `x dev --port ${neighbouringPort(input.port)} # leaves ${PORT_RANGE.max} free for the sync node`,
33
+ meta: { port: input.port },
34
+ });
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The port the sync node listens on. `PORT + 1`, and `0` stays `0` — the kernel picks, and adding
40
+ * one to it would pick a specific port instead.
41
+ *
42
+ * REFUSED at the top of the range, never clamped. `PORT_RANGE.max` is 65535 and `portValue`
43
+ * accepts it, so `x dev --port 65535` handed `Bun.serve` 65536 and the bare `RangeError` reached
44
+ * the terminal as `X_CLI_UNEXPECTED` with `fix: x doctor --json`. Clamping to 65534 would be worse
45
+ * than refusing: `PORT + 1` is the rule `docker/docker-compose.prod.yml` publishes `3001:3001`
46
+ * from and `docker/helm` derives `PORT = .port - 1` from, so a node quietly on `PORT - 1` is a
47
+ * socket nothing else in the deployment computes.
48
+ */
49
+ export function syncPortFor(port: number): number {
50
+ if (port === 0) return 0;
51
+ if (port >= PORT_RANGE.max) throw new SyncPortUnavailableError({ port });
52
+ return port + 1;
53
+ }
54
+
20
55
  /** What `startRoles` holds on to: where the node listens, and how to take it down. */
21
56
  export interface RunningSync {
22
57
  readonly url: string;
@@ -97,7 +132,7 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
97
132
  });
98
133
  await node.start();
99
134
  try {
100
- const listener = listenSyncNode(node, { port: options.port === 0 ? 0 : options.port + 1 });
135
+ const listener = listenSyncNode(node, { port: syncPortFor(options.port) });
101
136
  return {
102
137
  url: listener.url,
103
138
  stop: async () => {
@@ -4,8 +4,10 @@
4
4
  // browser drops every one of those declarations from, byte-for-byte identical to a working page
5
5
  // apart from the styling nobody sees missing. A silent failure is exactly what axiom 3 exists for.
6
6
 
7
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
7
8
  import type { Surface } from '@ultimat3/render';
8
- import { routeEntries, stylesFor } from '@ultimat3/render';
9
+ import { routeEntries } from '@ultimat3/render';
10
+ import { stylesFor } from '@ultimat3/render/server';
9
11
  import type { Finding } from './output';
10
12
 
11
13
  /** The stylesheet an app is expected to own, named in the fix so it is one edit, not a hunt. */
@@ -48,7 +50,7 @@ export function checkDocumentStyles(documents: readonly SurfaceDocument[]): read
48
50
  code: 'X_STYLES_GLOBAL_MISSING',
49
51
  cause: `a ${document.surface}/ document carries ${document.css.length} characters of CSS and defines no :root custom properties, so every var(--color-*) and var(--space-*) in it resolves to nothing`,
50
52
  fix: `add apps/web/${APP_GLOBAL_STYLESHEET} containing \`@use '@ultimat3/ui/global.scss';\` and apps/web/${APP_GLOBAL_MODULE} containing \`import './global.scss';\``,
51
- docs: 'https://ultimate.dev/errors/X_STYLES_GLOBAL_MISSING',
53
+ docs: ERROR_DOCS_URL,
52
54
  at: `apps/web/${APP_GLOBAL_STYLESHEET}`,
53
55
  }));
54
56
  }
package/src/drift.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
14
15
  import { describeEntities } from '@ultimat3/entity';
15
16
  import { countDeclaredEntities } from './app-entities';
16
17
  import { loadApp } from './app-load';
@@ -181,7 +182,7 @@ export async function checkSourceDrift(
181
182
  code: 'X_DB_DRIFT',
182
183
  cause: 'packages/db has a schema but no migration recorded it',
183
184
  fix: 'x db gen "initial"',
184
- docs: 'https://ultimate.dev/errors/X_DB_DRIFT',
185
+ docs: ERROR_DOCS_URL,
185
186
  at: MIGRATIONS_DIR,
186
187
  },
187
188
  ];
@@ -192,7 +193,7 @@ export async function checkSourceDrift(
192
193
  code: 'X_DB_DRIFT',
193
194
  cause: `schema hashes to ${current}, newest migration ${latest.file} recorded ${latest.hash}`,
194
195
  fix: 'x db gen "describe the change"',
195
- docs: 'https://ultimate.dev/errors/X_DB_DRIFT',
196
+ docs: ERROR_DOCS_URL,
196
197
  at: `${DB_PACKAGE}/src`,
197
198
  },
198
199
  ];
@@ -1,5 +1,5 @@
1
1
  // The X_* codes owned by @ultimat3/cli, and nothing else: the two lists, their titles, the one
2
- // registration call and `docsFor`. Every code names the exact command that resolves it, because
2
+ // registration call. Every code names the exact command that resolves it, because
3
3
  // the CLI is the surface an agent reads first — a failure here has to be actionable without a doc
4
4
  // lookup or a second round-trip. The classes that throw these codes live in `./errors`.
5
5
  import { registerErrorCodes } from '@ultimat3/core';
@@ -217,4 +217,11 @@ registerErrorCodes(
217
217
  Object.fromEntries(Object.entries(CLI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
218
218
  );
219
219
 
220
- export const docsFor = (code: CliErrorCode): string => `https://ultimate.dev/errors/${code}`;
220
+ // This file exports NO `docsFor(code)`, and adding one back is the defect. A CLI error passes no
221
+ // `docs:` at all — `UltimateError` fills it from `describeErrorCode(code).docs`, which is
222
+ // `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every code, never one per code, because `wiki/`
223
+ // is the framework's only public documentation surface and a code lives there in a TABLE ROW, which
224
+ // has no anchor. A `Finding` is a plain object with no constructor to fill it, so it carries
225
+ // `ERROR_DOCS_URL` imported from core — the same constant, not a second copy of it. The
226
+ // `https://ultimate.dev/errors/<code>` links `docsFor` built until 9.x answered 404, host included,
227
+ // on every error the CLI has ever thrown.
@@ -6,7 +6,7 @@
6
6
 
7
7
  // `join` is `node:`-only by necessity: Bun exposes no path-join primitive.
8
8
  import { join } from 'node:path';
9
- import { docsFor } from './error-codes';
9
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
10
10
  import { citedCommandProblem, loadCommandCatalog } from './fix-command';
11
11
  import { createHelperResolver } from './fix-imports';
12
12
  import { scanFixSites } from './fix-scan';
@@ -63,7 +63,7 @@ const fixFinding = (site: FixSite, problem: string): Finding => ({
63
63
  code: 'X_ERROR_FIX_INVALID',
64
64
  cause: problem,
65
65
  fix: `rewrite the fix at ${site.at}:${site.line} as a command to run, a call to paste, or an edit naming a file`,
66
- docs: docsFor('X_ERROR_FIX_INVALID'),
66
+ docs: ERROR_DOCS_URL,
67
67
  at: `${site.at}:${site.line}`,
68
68
  });
69
69
 
@@ -132,7 +132,7 @@ const undocumentedFinding = (code: string, at: string, line: number, page: strin
132
132
  code: 'X_ERROR_CODE_UNDOCUMENTED',
133
133
  cause: `${code} is declared at ${at}:${line} and ${page} has no entry for it`,
134
134
  fix: `add a row for ${code} to ${page}, with its cause and the command that fixes it`,
135
- docs: docsFor('X_ERROR_CODE_UNDOCUMENTED'),
135
+ docs: ERROR_DOCS_URL,
136
136
  at: page,
137
137
  });
138
138
 
@@ -163,7 +163,7 @@ const unregisteredFinding = (code: string, page: string): Finding => ({
163
163
  code: 'X_ERROR_CODE_UNREGISTERED',
164
164
  cause: `${page} documents ${code} as a live code and nothing registers it, so "x errors explain ${code}" refuses a code this page promises`,
165
165
  fix: `register ${code} through registerErrorCodes() in its package's src/errors.ts, or move its row under "${RESERVED_HEADING}" in ${page}`,
166
- docs: docsFor('X_ERROR_CODE_UNREGISTERED'),
166
+ docs: ERROR_DOCS_URL,
167
167
  at: page,
168
168
  });
169
169
 
@@ -249,7 +249,7 @@ export async function checkErrorCodeDocs(root: string, page: string): Promise<re
249
249
  code: 'X_ERROR_CODE_UNDOCUMENTED',
250
250
  cause: `the error reference ${page} does not exist, so no code can be documented`,
251
251
  fix: `create ${page} with a row per X_* code, or stop naming it as the error reference`,
252
- docs: docsFor('X_ERROR_CODE_UNDOCUMENTED'),
252
+ docs: ERROR_DOCS_URL,
253
253
  at: page,
254
254
  },
255
255
  ];
package/src/errors.ts CHANGED
@@ -2,7 +2,6 @@
2
2
  // that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
3
3
  // so a package importing a class does not pull the table and vice versa.
4
4
  import { UltimateError } from '@ultimat3/core';
5
- import { docsFor } from './error-codes';
6
5
 
7
6
  /** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
8
7
  export class UnknownCommandError extends UltimateError {
@@ -11,7 +10,6 @@ export class UnknownCommandError extends UltimateError {
11
10
  code: 'X_CLI_UNKNOWN_COMMAND',
12
11
  cause: `"x ${input.path}" is not a command (known: ${input.known.join(', ')})`,
13
12
  fix: input.suggestion === undefined ? 'x help' : `x ${input.suggestion}`,
14
- docs: docsFor('X_CLI_UNKNOWN_COMMAND'),
15
13
  });
16
14
  }
17
15
  }
@@ -27,7 +25,6 @@ export class BadFlagError extends UltimateError {
27
25
  code: 'X_CLI_BAD_FLAG',
28
26
  cause: `--${input.flag} on "x ${input.command}": ${input.reason}`,
29
27
  fix: input.fix ?? `x ${input.command} --help`,
30
- docs: docsFor('X_CLI_BAD_FLAG'),
31
28
  });
32
29
  }
33
30
  }
@@ -46,7 +43,6 @@ export class MissingPositionalError extends UltimateError {
46
43
  code: 'X_CLI_BAD_FLAG',
47
44
  cause: `"x ${input.command}" needs a <${input.positional}> positional and got none`,
48
45
  fix: input.example,
49
- docs: docsFor('X_CLI_BAD_FLAG'),
50
46
  });
51
47
  }
52
48
  }
@@ -70,7 +66,6 @@ export class MissingSubcommandError extends UltimateError {
70
66
  code: 'X_CLI_BAD_FLAG',
71
67
  cause: `"x ${input.command}" takes a subcommand and got none (one of: ${input.known.join(', ')})`,
72
68
  fix: `x help ${input.command}`,
73
- docs: docsFor('X_CLI_BAD_FLAG'),
74
69
  });
75
70
  }
76
71
  }
@@ -82,7 +77,6 @@ export class VerifyFailedError extends UltimateError {
82
77
  code: 'X_VERIFY_FAILED',
83
78
  cause: `${input.failed.length} verify step(s) failed: ${input.failed.join(', ')}`,
84
79
  fix: 'x verify --json',
85
- docs: docsFor('X_VERIFY_FAILED'),
86
80
  });
87
81
  }
88
82
  }
@@ -94,7 +88,6 @@ export class NotInAppError extends UltimateError {
94
88
  code: 'X_NOT_IN_APP',
95
89
  cause: `"x ${input.command}" must run inside an Ultimate app; no app.config.ts at or above ${input.from}`,
96
90
  fix: 'x new myapp && cd myapp',
97
- docs: docsFor('X_NOT_IN_APP'),
98
91
  });
99
92
  }
100
93
  }
@@ -106,7 +99,6 @@ export class BunVersionError extends UltimateError {
106
99
  code: 'X_BUN_VERSION',
107
100
  cause: `Bun ${input.found} is older than the required ${input.required}`,
108
101
  fix: 'bun upgrade',
109
- docs: docsFor('X_BUN_VERSION'),
110
102
  });
111
103
  }
112
104
  }
@@ -127,7 +119,6 @@ export class NoTestFilesError extends UltimateError {
127
119
  code: 'X_TEST_NO_FILES',
128
120
  cause: `no *.test.ts files${where} under ${input.root}`,
129
121
  fix: parts.length === 0 ? 'x test --json # run it from the repo root' : 'x test',
130
- docs: docsFor('X_TEST_NO_FILES'),
131
122
  });
132
123
  }
133
124
  }
@@ -146,7 +137,6 @@ export class ScaffoldPathEscapeError extends UltimateError {
146
137
  fix:
147
138
  input.fix ??
148
139
  `make the path relative to the app root with no ".." segment, then re-run: bun test packages/cli/src/scaffold-typecheck.contract.test.ts`,
149
- docs: docsFor('X_SCAFFOLD_PATH_ESCAPE'),
150
140
  });
151
141
  }
152
142
  }
@@ -163,7 +153,6 @@ export class GenerateJsonInvalidError extends UltimateError {
163
153
  code: 'X_GENERATE_JSON_INVALID',
164
154
  cause: `${input.path} is declared merge: 'json' but the generator's own contents for it do not parse as a JSON object`,
165
155
  fix: `fix the template that emits ${input.path}, then re-run: bun test packages/cli/src/cmd-generate.test.ts`,
166
- docs: docsFor('X_GENERATE_JSON_INVALID'),
167
156
  });
168
157
  }
169
158
  }
@@ -182,7 +171,6 @@ export class CatalogExistsError extends UltimateError {
182
171
  code: 'X_GENERATE_CONFLICT',
183
172
  cause: `${input.path} already exists`,
184
173
  fix: `x i18n sync ${input.locale}`,
185
- docs: docsFor('X_GENERATE_CONFLICT'),
186
174
  });
187
175
  }
188
176
  }
@@ -198,7 +186,6 @@ export class AppPackageInvalidError extends UltimateError {
198
186
  code: 'X_APP_PACKAGE_INVALID',
199
187
  cause: `${input.path} ${input.problem}, so the manifest has no app name or version to gate on`,
200
188
  fix: 'bun pm pkg set name=my-app version=0.1.0',
201
- docs: docsFor('X_APP_PACKAGE_INVALID'),
202
189
  });
203
190
  }
204
191
  }
@@ -216,7 +203,6 @@ export class ErrorCodeUnknownError extends UltimateError {
216
203
  input.suggestion === undefined
217
204
  ? 'x errors list --json'
218
205
  : `x errors explain ${input.suggestion}`,
219
- docs: docsFor('X_ERROR_CODE_UNKNOWN'),
220
206
  });
221
207
  }
222
208
  }
@@ -243,7 +229,6 @@ export class DeclarationUnknownError extends UltimateError {
243
229
  input.suggestion === undefined
244
230
  ? `x ${input.kind} list --json`
245
231
  : `x ${input.kind} ${input.verb ?? 'describe'} ${input.suggestion}`,
246
- docs: docsFor('X_DECLARATION_UNKNOWN'),
247
232
  });
248
233
  }
249
234
  }
@@ -255,7 +240,6 @@ export class JobUnknownError extends UltimateError {
255
240
  code: 'X_JOB_UNKNOWN',
256
241
  cause: `the "${input.driver}" queue holds no job with id "${input.id}"`,
257
242
  fix: 'x jobs ls --json',
258
- docs: docsFor('X_JOB_UNKNOWN'),
259
243
  });
260
244
  }
261
245
  }
@@ -274,7 +258,6 @@ export class FixTargetUnknownError extends UltimateError {
274
258
  input.suggestion === undefined
275
259
  ? 'x routes --json # every registered route file, app-root-relative'
276
260
  : `x fix boundary ${input.suggestion}`,
277
- docs: docsFor('X_FIX_TARGET_UNKNOWN'),
278
261
  });
279
262
  }
280
263
  }
@@ -290,7 +273,6 @@ export class BuildEntryMissingError extends UltimateError {
290
273
  code: 'X_BUILD_ENTRY_MISSING',
291
274
  cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
292
275
  fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
293
- docs: docsFor('X_BUILD_ENTRY_MISSING'),
294
276
  });
295
277
  }
296
278
  }
@@ -306,7 +288,6 @@ export class IslandBuildFailedError extends UltimateError {
306
288
  code: 'X_BUILD_FAILED',
307
289
  cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
308
290
  fix: `bun build --target browser ${input.file}`,
309
- docs: docsFor('X_BUILD_FAILED'),
310
291
  });
311
292
  }
312
293
  }
@@ -321,7 +302,6 @@ export class RoleUnknownError extends UltimateError {
321
302
  code: 'X_ROLE_UNKNOWN',
322
303
  cause: `ROLE="${input.role}" is not a role (known: ${input.known.join(', ')})`,
323
304
  fix: `docker run -e ROLE=web my-app:latest # one of: ${input.known.join(', ')}`,
324
- docs: docsFor('X_ROLE_UNKNOWN'),
325
305
  });
326
306
  }
327
307
  }
@@ -344,7 +324,6 @@ export class RuntimeDriverSplitError extends UltimateError {
344
324
  // queues, and "they match" is exactly the reading that makes this bug invisible.
345
325
  cause: `an app module installed a ${input.driver} driver (ambient: "${input.ambient}") that is not the object this boot captured ("${input.captured}"), so enqueues and claims would use different queues`,
346
326
  fix: `pass the driver to the boot instead of installing it from an app module: runRole({ root, env, runtime: { ${input.driver}: yourDriver } })`,
347
- docs: docsFor('X_RUNTIME_DRIVER_SPLIT'),
348
327
  });
349
328
  }
350
329
  }
@@ -362,7 +341,6 @@ export class PortInvalidError extends UltimateError {
362
341
  code: 'X_PORT_INVALID',
363
342
  cause: `${name}="${input.value}" is not a TCP port number between 0 and 65535`,
364
343
  fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090} my-app:latest`,
365
- docs: docsFor('X_PORT_INVALID'),
366
344
  });
367
345
  }
368
346
  }
@@ -382,7 +360,6 @@ export class EnvSchemaMissingError extends UltimateError {
382
360
  code: 'X_CONFIG_INVALID',
383
361
  cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
384
362
  fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
385
- docs: docsFor('X_CONFIG_INVALID'),
386
363
  });
387
364
  }
388
365
  }
@@ -394,7 +371,6 @@ export class CliNotImplementedError extends UltimateError {
394
371
  code: 'X_NOT_IMPLEMENTED',
395
372
  cause: `${input.feature} is not implemented in this build`,
396
373
  fix: input.fix,
397
- docs: docsFor('X_NOT_IMPLEMENTED'),
398
374
  });
399
375
  }
400
376
  }
@@ -410,7 +386,7 @@ export class CliNotImplementedError extends UltimateError {
410
386
  */
411
387
  export class StorageUnwritableError extends UltimateError {
412
388
  constructor(cause: string, fix: string) {
413
- super({ code: 'X_STORAGE_UNWRITABLE', cause, fix, docs: docsFor('X_STORAGE_UNWRITABLE') });
389
+ super({ code: 'X_STORAGE_UNWRITABLE', cause, fix });
414
390
  }
415
391
  }
416
392
 
@@ -435,7 +411,6 @@ export class LocalDiskUnsafeError extends UltimateError {
435
411
  `disk at ${input.root} — and with no STORAGE_SIGNING_SECRET it would sign upload grants ` +
436
412
  'with the development key published in @ultimat3/storage',
437
413
  fix: 'export S3_ENDPOINT=https://s3.example.com S3_BUCKET=my-app-uploads # or keep the disk on a mounted volume: export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
438
- docs: docsFor('X_ENV_MISSING'),
439
414
  });
440
415
  }
441
416
  }
@@ -450,7 +425,6 @@ export class SecretsEditorMissingError extends UltimateError {
450
425
  code: 'X_SECRETS_EDITOR_MISSING',
451
426
  cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
452
427
  fix: 'EDITOR=nano x secrets edit',
453
- docs: docsFor('X_SECRETS_EDITOR_MISSING'),
454
428
  });
455
429
  }
456
430
  }
@@ -466,7 +440,6 @@ export class SecretsEditFailedError extends UltimateError {
466
440
  code: 'X_SECRETS_EDIT_FAILED',
467
441
  cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
468
442
  fix: 'x secrets edit',
469
- docs: docsFor('X_SECRETS_EDIT_FAILED'),
470
443
  });
471
444
  }
472
445
  }
@@ -483,7 +456,6 @@ export class SecretsExistsError extends UltimateError {
483
456
  code: 'X_GENERATE_CONFLICT',
484
457
  cause: `${input.path} already exists, and x secrets init would replace it`,
485
458
  fix: input.fix,
486
- docs: docsFor('X_GENERATE_CONFLICT'),
487
459
  });
488
460
  }
489
461
  }
package/src/flag-reads.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  // `join`/`relative` are `node:`-only by necessity: Bun exposes no path-join primitive.
11
11
  import { join, relative } from 'node:path';
12
- import { docsFor } from './error-codes';
12
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
13
13
  import type { Finding } from './output';
14
14
  import type { CommandSpec, FlagSpec } from './parse';
15
15
  import { GLOBAL_FLAGS } from './parse';
@@ -64,7 +64,7 @@ const unreadFinding = (declared: DeclaredFlag, at: string): Finding => ({
64
64
  code: 'X_CLI_FLAG_UNREAD',
65
65
  cause: `x ${declared.command} declares --${declared.flag.name} ("${declared.flag.summary}") and no file in the CLI's source reads it, so the flag parses and changes nothing`,
66
66
  fix: `read it in ${at} with flag${declared.flag.type === 'boolean' ? 'Bool' : 'String'}(ctx.args, '${declared.flag.name}'), or delete it from the spec's flags`,
67
- docs: docsFor('X_CLI_FLAG_UNREAD'),
67
+ docs: ERROR_DOCS_URL,
68
68
  at,
69
69
  });
70
70
 
@@ -7,6 +7,7 @@
7
7
  // app root, and `node:path` is the only API that resolves one. `node:fs` for the exists check.
8
8
  import { existsSync } from 'node:fs';
9
9
  import { resolve, sep } from 'node:path';
10
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
10
11
  import { GenerateJsonInvalidError, ScaffoldPathEscapeError } from './errors';
11
12
  import { mergeJsonDeep } from './json-merge';
12
13
  import type { Finding } from './output';
@@ -125,7 +126,7 @@ async function planJsonMerge(
125
126
  code: 'X_GENERATE_CONFLICT',
126
127
  cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
127
128
  fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
128
- docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
129
+ docs: ERROR_DOCS_URL,
129
130
  at: file.path,
130
131
  },
131
132
  };
@@ -178,7 +179,7 @@ function planFile(
178
179
  // when run, and a `fix:` is copied and pasted verbatim. Same construction as
179
180
  // `generate-kinds.ts`'s `assertSurfaceSupported`.
180
181
  fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
181
- docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
182
+ docs: ERROR_DOCS_URL,
182
183
  at: file.path,
183
184
  },
184
185
  };
package/src/guards.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import { existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
- import { renderCauseValue, renderThrowable } from '@ultimat3/core';
12
+ import { ERROR_DOCS_URL, renderCauseValue, renderThrowable } from '@ultimat3/core';
13
13
  import { fixProblem } from './error-contract';
14
14
  import type { Finding } from './output';
15
15
  import type { HostCheck } from './verify-step';
@@ -105,7 +105,7 @@ const failed = (path: string, cause: string): Finding => ({
105
105
  code: 'X_GUARD_FAILED',
106
106
  cause,
107
107
  fix: `return a finding from ${path} instead of throwing, then: x verify`,
108
- docs: 'https://ultimate.dev/errors/X_GUARD_FAILED',
108
+ docs: ERROR_DOCS_URL,
109
109
  at: path,
110
110
  });
111
111
 
@@ -113,7 +113,7 @@ const invalid = (path: string, cause: string): Finding => ({
113
113
  code: 'X_GUARD_INVALID',
114
114
  cause,
115
115
  fix: `export a \`guard\` object — { summary, check } — from ${path}, then: x verify`,
116
- docs: 'https://ultimate.dev/errors/X_GUARD_INVALID',
116
+ docs: ERROR_DOCS_URL,
117
117
  at: path,
118
118
  });
119
119
 
@@ -121,7 +121,7 @@ const findingInvalid = (path: string, cause: string): Finding => ({
121
121
  code: 'X_GUARD_FINDING_INVALID',
122
122
  cause: `${path} returned a finding that is not one: ${cause}`,
123
123
  fix: `rewrite what ${path} returns as a code, a cause and a fix naming a command or a file, then: x verify`,
124
- docs: 'https://ultimate.dev/errors/X_GUARD_FINDING_INVALID',
124
+ docs: ERROR_DOCS_URL,
125
125
  at: path,
126
126
  });
127
127
 
package/src/index.ts CHANGED
@@ -221,12 +221,7 @@ export {
221
221
  } from './output';
222
222
  export type { CommandSpec, FlagSpec, ParsedArgs } from './parse';
223
223
  export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from './parse';
224
- export type {
225
- PrerenderedPage,
226
- PrerenderOptions,
227
- PrerenderReport,
228
- UnmeasuredRoute,
229
- } from './prerender';
224
+ export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
230
225
  export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
231
226
  export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
232
227
  export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
@@ -254,6 +249,7 @@ export type {
254
249
  SkippedRoute,
255
250
  SkipReason,
256
251
  StaticReport,
252
+ UnmeasuredRoute,
257
253
  } from './static-report';
258
254
  export {
259
255
  parseStaticReport,