@ultimat3/cli 7.0.0 → 9.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 (78) hide show
  1. package/CLAUDE.md +24 -4
  2. package/README.md +8 -3
  3. package/package.json +26 -25
  4. package/src/app-boundaries.ts +55 -5
  5. package/src/app-load.ts +7 -0
  6. package/src/bin.ts +6 -3
  7. package/src/ci-log.ts +0 -0
  8. package/src/cmd-db-backfill.ts +240 -0
  9. package/src/cmd-db-branch.ts +3 -2
  10. package/src/cmd-db.ts +35 -156
  11. package/src/cmd-deploy.ts +43 -6
  12. package/src/cmd-dev.ts +7 -1
  13. package/src/cmd-errors.ts +2 -3
  14. package/src/cmd-fix.ts +3 -3
  15. package/src/cmd-i18n.ts +67 -5
  16. package/src/cmd-jobs.ts +27 -4
  17. package/src/cmd-mcp.ts +18 -9
  18. package/src/cmd-new.ts +91 -4
  19. package/src/cmd-policy.ts +3 -2
  20. package/src/cmd-pr.ts +55 -4
  21. package/src/cmd-registries.ts +3 -2
  22. package/src/cmd-shot.ts +68 -6
  23. package/src/cmd-tasks.ts +9 -4
  24. package/src/cmd-verify.ts +47 -6
  25. package/src/dev-assets.ts +4 -7
  26. package/src/dev-cache.ts +130 -33
  27. package/src/dev-lock.ts +124 -12
  28. package/src/dev-purge.ts +120 -0
  29. package/src/dev-queue.ts +39 -9
  30. package/src/dev-render.ts +11 -14
  31. package/src/dev-replicator.ts +3 -7
  32. package/src/dev-roles-fixture.ts +1 -1
  33. package/src/dev-roles.ts +40 -8
  34. package/src/dev-runtime.ts +137 -6
  35. package/src/dev-sync.ts +9 -4
  36. package/src/dispatch.ts +35 -5
  37. package/src/document-styles.ts +2 -1
  38. package/src/drift.ts +52 -7
  39. package/src/error-codes.ts +5 -0
  40. package/src/framework-scope.ts +57 -5
  41. package/src/generate-kinds.ts +19 -1
  42. package/src/i18n-registration.ts +67 -4
  43. package/src/index.ts +1 -1
  44. package/src/island-bundle.ts +2 -6
  45. package/src/island-styles.ts +1 -1
  46. package/src/jobs-report.ts +10 -13
  47. package/src/mcp-errors.ts +3 -0
  48. package/src/messages.ts +12 -0
  49. package/src/output.ts +22 -2
  50. package/src/parse.ts +81 -37
  51. package/src/prerender.ts +2 -1
  52. package/src/realtime-browser-probe-fixture.ts +9 -0
  53. package/src/runtime-overrides.ts +12 -4
  54. package/src/serve.ts +1 -1
  55. package/src/shot-settle.ts +57 -0
  56. package/src/shot-verdict.ts +27 -4
  57. package/src/solid-loader.ts +1 -1
  58. package/src/style-csp.ts +2 -1
  59. package/src/sync-authenticator.ts +86 -14
  60. package/src/templates/guard-bare-error.ts +122 -0
  61. package/src/templates/guard-raw-colour.ts +138 -0
  62. package/src/templates/guard-untranslated-string.ts +138 -0
  63. package/src/templates/guard-unzoned-date.ts +142 -0
  64. package/src/templates/index.ts +3 -0
  65. package/src/templates/island.ts +2 -1
  66. package/src/templates/route.ts +1 -1
  67. package/src/templates/scaffold-app.ts +3 -82
  68. package/src/templates/scaffold-container.ts +30 -4
  69. package/src/templates/scaffold-db-package.ts +14 -6
  70. package/src/templates/scaffold-docs.ts +34 -16
  71. package/src/templates/scaffold-entries.ts +131 -0
  72. package/src/templates/scaffold-guards.ts +26 -0
  73. package/src/templates/scaffold-repo.ts +40 -7
  74. package/src/test-select.ts +4 -3
  75. package/src/verify-run.ts +25 -3
  76. package/src/verify-step.ts +11 -2
  77. package/src/verify-tests.ts +11 -3
  78. package/src/write-line.ts +23 -5
@@ -4,13 +4,9 @@
4
4
  // roles reading the other end of that transport.
5
5
 
6
6
  import { describeEntities } from '@ultimat3/entity';
7
- import type { Replicator, Transport } from '@ultimat3/realtime';
8
- import {
9
- createReplicator,
10
- ReplicatorSlotHeldError,
11
- replicatorLockKey,
12
- selectChangeFeed,
13
- } from '@ultimat3/realtime';
7
+ import { ReplicatorSlotHeldError } from '@ultimat3/realtime';
8
+ import type { Replicator, Transport } from '@ultimat3/realtime/server';
9
+ import { createReplicator, replicatorLockKey, selectChangeFeed } from '@ultimat3/realtime/server';
14
10
  import type { DevServices, Env } from './dev-services';
15
11
  import { BadFlagError } from './errors';
16
12
 
@@ -16,7 +16,7 @@ import {
16
16
  resetTasks,
17
17
  } from '@ultimat3/jobs';
18
18
  import { createMemoryDriver as createMemoryMailDriver } from '@ultimat3/mail';
19
- import { DEFAULT_PRESENCE_TTL_MS, InProcessTransport } from '@ultimat3/realtime';
19
+ import { DEFAULT_PRESENCE_TTL_MS, InProcessTransport } from '@ultimat3/realtime/server';
20
20
  import { defineStorage, localDriver } from '@ultimat3/storage';
21
21
  import type { RunningServices } from './dev-runtime';
22
22
  import { resolveServices } from './dev-services';
package/src/dev-roles.ts CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { Role } from '@ultimat3/core';
10
10
  import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
11
- import type { Route, ServerHandle, ServerHooks } from '@ultimat3/http';
11
+ import type { RateLimitStore, Route, ServerHandle, ServerHooks } from '@ultimat3/http';
12
12
  import { configuredAuthenticator, createServer, defineHttpConfig } from '@ultimat3/http';
13
13
  import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
14
14
  import {
@@ -195,11 +195,43 @@ export function trustedHopsFromEnv(env: Env): number | null {
195
195
  return hops;
196
196
  }
197
197
 
198
+ /**
199
+ * A deployment that substituted a PER-PROCESS store is enforcing every declared number once per
200
+ * replica, and the shipped chart runs three. Not refused — `assertRateLimitScope` only fires on a
201
+ * `'shared'` declaration, and the scope below is DERIVED from the store, so the two can never
202
+ * contradict each other — but the multiplier is stated, in the `warnIfUnauthenticatable` shape:
203
+ * loud, coded, and naming the call that fixes it.
204
+ *
205
+ * Only for a store the host SUPPLIED. A boot with no store at all resolved one of its own
206
+ * (`startServices`), so the remaining silent case is a hand-built `RunningServices` in a test.
207
+ */
208
+ function warnIfProcessScoped(store: RateLimitStore): void {
209
+ if (store.scope === 'shared') return;
210
+ logger.warn(
211
+ `X_CONFIG_INVALID: the rate-limit store this deployment passed keeps its counters per process, so every limit the app declares is enforced once per replica — docker/helm/values.yaml runs roles.web.replicas: 3, which is 3x every number — fix: drop runtime.rateLimitStore and the boot installs postgresRateLimitStore({ executor }) on the pool it already opened`,
212
+ );
213
+ }
214
+
215
+ /**
216
+ * Where this web role's limiter keeps its counters: what the deployment SUBSTITUTED, else the
217
+ * shared Postgres store `startServices` resolved over the pool this boot already opened.
218
+ *
219
+ * One expression, one answer, in the order `RuntimeOverrides` documents — an override REPLACES the
220
+ * resolved default rather than sitting beside it. `undefined` is reachable only from a hand-built
221
+ * runtime, which is `createServer`'s per-process memory store and `scope: 'process'` below.
222
+ */
223
+ function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefined {
224
+ const supplied = options.overrides?.rateLimitStore;
225
+ if (supplied === undefined) return options.runtime.rateLimitStore;
226
+ warnIfProcessScoped(supplied);
227
+ return supplied;
228
+ }
229
+
198
230
  function startWeb(options: StartRolesOptions): ServerHandle {
199
231
  warnIfUnauthenticatable(options.routes);
200
232
  const binding = options.http ?? DEV_BINDING;
201
233
  const hops = trustedHopsFromEnv(options.env);
202
- const store = options.overrides?.rateLimitStore;
234
+ const store = rateLimitStoreFor(options);
203
235
  return createServer({
204
236
  routes: options.routes,
205
237
  role: 'web',
@@ -218,12 +250,12 @@ function startWeb(options: StartRolesOptions): ServerHandle {
218
250
  signInPath: options.signInPath ?? null,
219
251
  // One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
220
252
  ...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
221
- // `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error: the
222
- // old `'process'` default meant the shipped chart's three `web` replicas enforced
223
- // `login: { limit: 5 }` as fifteen attempts, with `x verify` green. It is DERIVED from the
224
- // store rather than hardcoded — a deployment that hands `runtime.rateLimitStore` a shared
225
- // store is declaring the fleet-wide numbers, and `assertRateLimitScope` then holds the two
226
- // halves together instead of a literal here quietly contradicting the store beside it.
253
+ // `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error, and it
254
+ // is DERIVED from the store rather than hardcoded — a literal here would be a second
255
+ // declaration quietly contradicting the object beside it, and `assertRateLimitScope` holds
256
+ // the two halves together. It answered `'process'` on every real boot until `startServices`
257
+ // resolved a store, so the shipped chart's three `web` replicas enforced `login: { limit: 5 }`
258
+ // as fifteen attempts, with `x verify` green.
227
259
  rateLimit: { scope: store?.scope ?? 'process' },
228
260
  // Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
229
261
  // nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
@@ -4,9 +4,19 @@
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
- import { isLocal, renderThrowable, resolveEnvironment } from '@ultimat3/core';
11
+ import {
12
+ isLocal,
13
+ registerReadinessCheck,
14
+ renderThrowable,
15
+ resolveEnvironment,
16
+ systemClock,
17
+ } from '@ultimat3/core';
18
+ import type { RateLimitStore } from '@ultimat3/http';
19
+ import { postgresRateLimitStore } from '@ultimat3/http';
10
20
  import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
11
21
  import type { MailDriver } from '@ultimat3/mail';
12
22
  import {
@@ -16,13 +26,14 @@ import {
16
26
  selectMailDriver,
17
27
  setMailDriver,
18
28
  } from '@ultimat3/mail';
19
- import type { Transport, TransportSelection } from '@ultimat3/realtime';
20
- import { selectTransport } from '@ultimat3/realtime';
29
+ import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
30
+ import { selectTransport } from '@ultimat3/realtime/server';
21
31
  import type { Storage } from '@ultimat3/storage';
22
32
  import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
23
- import { startCacheTiers } from './dev-cache';
33
+ import { loadCacheTiers, startCacheTiers } from './dev-cache';
34
+ import { installRetentionSweep } from './dev-purge';
24
35
  import type { DevDbClient } from './dev-queue';
25
- import { startQueue } from './dev-queue';
36
+ import { pgExecutorFor, startQueue } from './dev-queue';
26
37
  import type { DevServices, Env } from './dev-services';
27
38
  import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
28
39
  import { msg } from './messages';
@@ -51,6 +62,20 @@ export interface RunningServices {
51
62
  * would show members leaving that never left.
52
63
  */
53
64
  readonly presenceTtlMs: number;
65
+ /**
66
+ * Where the HTTP rate limiter keeps its counters, over the pool this boot already opened.
67
+ *
68
+ * Resolved HERE and not at `startWeb` for the reason every other driver is: `startServices` is
69
+ * what holds the connection, and a store built anywhere else would open a second pool against a
70
+ * url this boot resolved once. Until it did, no boot installed one at all — so `rateLimit.scope`
71
+ * derived to `'process'` on every deployment the framework produces, while `docker/helm` runs
72
+ * `roles.web.replicas: 3` and `x new` scaffolds two.
73
+ *
74
+ * OPTIONAL because a `RunningServices` can be hand-built: a test's fixture runtime has a stub
75
+ * client and no shared store, which is `createServer`'s memory store and `scope: 'process'` —
76
+ * the honest answer for it, and the one `startWeb` derives.
77
+ */
78
+ readonly rateLimitStore?: RateLimitStore;
54
79
  readonly purge: PurgeDriver;
55
80
  /** Same rule as `mailDetail`: the env key that selected the CDN, never the token behind it. */
56
81
  readonly purgeDetail: string;
@@ -176,6 +201,59 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
176
201
  return defineStorage({ disks: { local: localDriver({ root }) }, default: 'local' });
177
202
  }
178
203
 
204
+ /**
205
+ * A readiness check over a dependency that can only be asked asynchronously.
206
+ *
207
+ * `ReadinessCheck` is synchronous and that is the mechanism, not a limitation: a probe that awaits
208
+ * a network call takes as long as the dependency does, so a slow database makes `/readyz` miss its
209
+ * `timeoutSeconds`, the kubelet reads that as unready, and capacity is pulled from an already
210
+ * struggling system — the outage the probe existed to prevent, caused by the probe.
211
+ *
212
+ * So the read answers the PREVIOUS probe and schedules the next, which is the standard cached
213
+ * health shape: staleness is one poll period, and the poll period is the operator's own
214
+ * `periodSeconds`. Deliberately not a `setInterval`: a timer would probe a process nobody is
215
+ * asking about, and in `x dev` every probe is a statement — one span, one trace id — so a
216
+ * one-per-second heartbeat would evict every real request from `/_x/timeline` inside a minute.
217
+ *
218
+ * It starts `true` because it is already proven: `startQueue` pinged this pool before this line
219
+ * ran, and a boot that could not would have thrown instead of reaching here.
220
+ */
221
+ function probedReadinessCheck(name: string, probe: () => Promise<unknown>): () => void {
222
+ let live = true;
223
+ let inFlight = false;
224
+ const refresh = (): void => {
225
+ if (inFlight) return;
226
+ inFlight = true;
227
+ void probe()
228
+ .then(
229
+ () => {
230
+ live = true;
231
+ },
232
+ // Every rejection, including the coded `X_DB_UNAVAILABLE` a closed pool answers with. A
233
+ // check that threw would be reported as failing anyway; catching it here keeps the reason
234
+ // out of the endpoint's own path, where nothing could report it.
235
+ () => {
236
+ live = false;
237
+ },
238
+ )
239
+ .finally(() => {
240
+ inFlight = false;
241
+ });
242
+ };
243
+ return registerReadinessCheck(name, () => {
244
+ refresh();
245
+ return live;
246
+ });
247
+ }
248
+
249
+ /** A transport that says whether it is connected. NATS does; the in-process bus has nothing to be. */
250
+ interface ConnectableTransport {
251
+ readonly connected: boolean;
252
+ }
253
+
254
+ const saysConnected = (transport: Transport): transport is Transport & ConnectableTransport =>
255
+ typeof (transport as Partial<ConnectableTransport>).connected === 'boolean';
256
+
179
257
  /**
180
258
  * Release what has already started, newest first, and return every failure instead of throwing on
181
259
  * the first: a step that rejects must not skip the ones after it, or one transport that will not
@@ -214,10 +292,45 @@ export async function startServices(
214
292
  const bus: TransportSelection = selectTransport(env);
215
293
  const queue = await startQueue(services, overrides);
216
294
  const { db, jobs, outbox, events } = queue;
295
+ // The same executor the jobs driver, the outbox, the event bus and the idempotency store run
296
+ // on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
297
+ // `undefined`), one wrapper.
298
+ const executor = pgExecutorFor(db);
299
+ const rateLimitStore = postgresRateLimitStore({ executor });
217
300
  // Boot is a sequence of external resources, and every step after the first can reject — the
218
301
  // queue is already up, so from here an unwind must release it exactly like everything after it.
302
+ // Which is why the `try` opens on the NEXT line and not eight steps further down: it did, and
303
+ // the steps above it registered process-wide state (`configureAuthLimiters`, the `purge()` job
304
+ // and its `task`) that throws on a name a previous boot in this process left in the registry —
305
+ // `X_JOB_NAME_TAKEN`, outside the unwind, so `x dev` exited holding the PGlite lock, the pool
306
+ // and the ambient accessors. Nothing may be pushed onto `started` from outside this block.
219
307
  const started: (() => void | Promise<void>)[] = [() => queue.stop()];
220
308
  try {
309
+ // Where failed sign-ins are counted, installed BEFORE `loadApp` for the reason the idempotency
310
+ // store is: `defineAuth` is the app's call and it runs when the app's modules import, so a seam
311
+ // filled afterwards is one every app would have to fill itself. A FACTORY and not a limiter —
312
+ // the app declares `maxAttempts`/`windowMs`/`lockoutMs` and this boot has not read them yet, so
313
+ // a limiter built here would be refused by `assertAuthLimiterPolicy` on any app that tuned one.
314
+ //
315
+ // Until this line every deployment the framework produces counted lockouts per POD, while
316
+ // `x new` scaffolds `replicas: 2` and `docker/helm` runs three — `maxAttempts × N` guesses per
317
+ // account, and a lockout one replica established invisible to the rest.
318
+ configureAuthLimiters((policy) =>
319
+ postgresAuthLimiter({ executor, clock: systemClock, policy }),
320
+ );
321
+ started.push(() => resetAuthLimiters());
322
+ // The hourly sweep over the three framework tables this boot is responsible for. Every one of
323
+ // them ships a `purgeExpired()` that nothing called, so every row written was a row kept —
324
+ // `x_rate_limit` takes one upsert per request the web role serves, assets included.
325
+ started.push(
326
+ installRetentionSweep({ idempotency: queue.idempotency, rateLimit: rateLimitStore }),
327
+ );
328
+ // One readiness check per resource this boot OWNS, released with it. Nothing in the tree
329
+ // registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
330
+ // `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `dev-roles.ts` calls it
331
+ // before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
332
+ // healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
333
+ started.push(probedReadinessCheck('database', () => db.ping()));
221
334
  // Dialled here rather than at selection: an unreachable bus must fail at `x dev`, not on the
222
335
  // first change nobody receives, and the socket is a resource the unwind below has to release.
223
336
  // A supplied transport is already connected and is NOT closed here: whoever built it owns its
@@ -228,6 +341,14 @@ export async function startServices(
228
341
  started.push(() => bus.transport.close());
229
342
  transport = bus.transport;
230
343
  }
344
+ // Only a transport that can be disconnected gets a check. `NatsTransport.connected` is a
345
+ // synchronous getter over the client's own state, so no probe is needed; the in-process bus
346
+ // has nothing to lose a connection to, and a check that can only answer `true` is a number in
347
+ // `registered` that means nothing.
348
+ if (saysConnected(transport)) {
349
+ const connectable = transport;
350
+ started.push(registerReadinessCheck('transport', () => connectable.connected));
351
+ }
231
352
  const storage = startStorage(services, env, overrides?.storage);
232
353
  // With no credential this is the memory driver: caught, not sent, so the `/_x` mail panel can
233
354
  // show what a template renders in every locale without a mailbox or a message escaping to a
@@ -243,7 +364,13 @@ export async function startServices(
243
364
  // recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
244
365
  // this boot is the only thing that registers one, and a tier left behind would purge for a
245
366
  // process that has stopped.
246
- started.push(startCacheTiers({ env, purge, transport }));
367
+ // The ladder is the app's declaration, never the environment's. `cache.tiers` was declared,
368
+ // validated and documented while NOTHING read it — so an app asking for one rung got four,
369
+ // and one asking for a shared tier got whatever `REDIS_URL` happened to say. `.x` is
370
+ // `resolveServices`' own join, so its parent is the app root: the one fact this function needs
371
+ // and does not already carry.
372
+ const tiers = await loadCacheTiers(dirname(services.stateDir));
373
+ started.push(startCacheTiers({ env, purge, transport, tiers }));
247
374
 
248
375
  return {
249
376
  services,
@@ -255,6 +382,10 @@ export async function startServices(
255
382
  storage,
256
383
  mail,
257
384
  mailDetail: selection.detail,
385
+ // Built above, beside the auth limiter factory and the retention sweep that purges its
386
+ // table: three readers of one executor, resolved once. `startWeb` is what an override
387
+ // replaces it at.
388
+ rateLimitStore,
258
389
  // The env key that selected the bus — or the honest answer that no env key did, because a
259
390
  // boot line reading `NATS_URL` over a transport the host handed in is a lie a script parses.
260
391
  transportDetail: overrides?.transport === undefined ? bus.detail : 'runtime override',
package/src/dev-sync.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  PresenceRegistry,
14
14
  RingChangeBuffer,
15
15
  SocketRegistry,
16
- } from '@ultimat3/realtime';
16
+ } from '@ultimat3/realtime/server';
17
17
  import type { StartRolesOptions } from './dev-roles';
18
18
  import { syncAuthenticator } from './sync-authenticator';
19
19
 
@@ -69,9 +69,14 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
69
69
  const hub = new ChannelHub({ transport: options.runtime.transport, sockets });
70
70
  // The node evaluated no credential of its own and no host ever handed it one, so every socket
71
71
  // the framework opened was anonymous and every guard, gate, presence entry and tenant cap
72
- // decided against `null`. An explicit override first — only that one can carry an `expiresAt`
73
- // and a `refresh`, which is the whole of re-authorization — then the app's own HTTP resolver,
74
- // then nothing at all, which is what `x dev` with no authenticator should stay.
72
+ // decided against `null`. An explicit override first, then the app's own HTTP resolver, then
73
+ // nothing at all, which is what `x dev` with no authenticator should stay.
74
+ //
75
+ // BOTH of the first two re-authorize: `syncAuthenticator` carries an `expiresAt` and a `refresh`
76
+ // of its own (`SYNC_GRANT_TTL_MS`), re-asking the app's resolver with the upgrade's own
77
+ // `cookie`/`authorization`, so `logout` closes the socket and not only the HTTP session. The
78
+ // override is how a deployment states a window its credential already declares (a token's
79
+ // `exp`), or resolves identity from a header the adapter deliberately does not retain.
75
80
  const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
76
81
  const node = createSyncNode({
77
82
  hub,
package/src/dispatch.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // path as results, so a failure is machine-readable exactly like a success.
4
4
 
5
5
  import { isAbsolute, resolve } from 'node:path';
6
+ import { setLogStream } from '@ultimat3/core';
6
7
  import { requireAppRoot, requireBunVersion } from './app-root';
7
8
  import { createHelpCommand } from './cmd-help';
8
9
  import { plannedCommandFor } from './cmd-planned';
@@ -23,8 +24,26 @@ export interface DispatchOptions {
23
24
  readonly bunVersion: string;
24
25
  readonly runner?: Runner;
25
26
  readonly write: (line: string) => void;
27
+ /**
28
+ * Where a result declaring `stream: 'stderr'` goes. Optional, and it falls back to `write`: an
29
+ * embedding caller that supplies one sink must still SEE the line, and `create-ultimate`'s entry
30
+ * point — which can only ever reach `x new` — would otherwise have to declare a sink for a case
31
+ * it cannot produce. `bin.ts` passes the real fd 2.
32
+ */
33
+ readonly writeError?: (line: string) => void;
26
34
  }
27
35
 
36
+ /**
37
+ * Which sink a result is written to. One function because `dispatch` writes in five places — the
38
+ * version refusal, the parse failure, the unknown command, the result and the catch — and a rule
39
+ * about fd 1 that four of them apply is not a rule.
40
+ */
41
+ export const sinkFor = (
42
+ result: CommandResult,
43
+ options: Pick<DispatchOptions, 'write' | 'writeError'>,
44
+ ): ((line: string) => void) =>
45
+ result.stream === 'stderr' ? (options.writeError ?? options.write) : options.write;
46
+
28
47
  const resolveCwd = (cwd: string, flag: string | undefined): string => {
29
48
  if (flag === undefined) return cwd;
30
49
  return isAbsolute(flag) ? flag : resolve(cwd, flag);
@@ -48,7 +67,8 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
48
67
  try {
49
68
  requireBunVersion(options.bunVersion);
50
69
  } catch (error) {
51
- options.write(render(errorResult('x', error), wantsJson(options.argv)));
70
+ const failure = errorResult('x', error);
71
+ sinkFor(failure, options)(render(failure, wantsJson(options.argv)));
52
72
  return 1;
53
73
  }
54
74
 
@@ -70,7 +90,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
70
90
  // `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
71
91
  // an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
72
92
  const result = errorResult(planned?.name ?? 'x', failure);
73
- options.write(render(result, wantsJson(options.argv)));
93
+ sinkFor(result, options)(render(result, wantsJson(options.argv)));
74
94
  return 1;
75
95
  }
76
96
 
@@ -83,7 +103,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
83
103
  known: SPECS.map((spec) => spec.name),
84
104
  }),
85
105
  );
86
- options.write(render(result, args.json));
106
+ sinkFor(result, options)(render(result, args.json));
87
107
  return 1;
88
108
  }
89
109
 
@@ -105,6 +125,16 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
105
125
  };
106
126
 
107
127
  try {
128
+ // `--json` means fd 1 carries ONE document, and every command that boots an app logs through
129
+ // core's module-scope `logger`, whose default writer is stdout: `x db migrate --json` printed
130
+ // `ultimate migrate applied` and then the command's own object, so `json.load` on the output
131
+ // of a command whose whole contract is `--json` raised on the second. Decided HERE, once, for
132
+ // all thirty commands — a rule each booting command had to remember is a rule the next one
133
+ // forgets. A server's stdout stays its log stream; this is about the CLI process only.
134
+ // Set on EVERY dispatch, both ways: the stream is process-wide state, so `if (args.json)`
135
+ // alone left a JSON run's `stderr` in place for the next non-JSON one — one `x` process
136
+ // dispatching twice (the MCP host, a test, an embedding caller) lost its boot logs off stdout.
137
+ setLogStream(args.json ? 'stderr' : 'stdout');
108
138
  // The reader `CommandSpec.requiresApp` never had. Its doc said "the dispatcher enforces it" and
109
139
  // `dispatch` did not read the field at all: the guarantee held only because all 17 declaring
110
140
  // commands happen to call `requireAppRoot` themselves, so a new command that declares it and
@@ -115,7 +145,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
115
145
  // command, which declares nothing: usage for a command must be readable from anywhere.
116
146
  if (target.spec.requiresApp === true) requireAppRoot(target.spec.name, ctx.cwd);
117
147
  const result = await target.run(ctx);
118
- options.write(render(result, args.json, args.flags.get('verbose') === true));
148
+ sinkFor(result, options)(render(result, args.json, args.flags.get('verbose') === true));
119
149
  // `x dev` and `x mcp serve --transport http` are still listening here: report first, so the
120
150
  // url is on stdout the moment it is reachable, then stay in the process until the drain that
121
151
  // stops them. Without this the exit code below is what takes the server down.
@@ -123,7 +153,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
123
153
  return exitCodeFor(result);
124
154
  } catch (error) {
125
155
  const result = errorResult(args.command, error);
126
- options.write(render(result, args.json));
156
+ sinkFor(result, options)(render(result, args.json));
127
157
  return 1;
128
158
  }
129
159
  }
@@ -5,7 +5,8 @@
5
5
  // apart from the styling nobody sees missing. A silent failure is exactly what axiom 3 exists for.
6
6
 
7
7
  import type { Surface } from '@ultimat3/render';
8
- import { routeEntries, stylesFor } from '@ultimat3/render';
8
+ import { routeEntries } from '@ultimat3/render';
9
+ import { stylesFor } from '@ultimat3/render/server';
9
10
  import type { Finding } from './output';
10
11
 
11
12
  /** The stylesheet an app is expected to own, named in the fix so it is one edit, not a hunt. */
package/src/drift.ts CHANGED
@@ -11,7 +11,9 @@
11
11
 
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { describeEntities } from '@ultimat3/entity';
14
15
  import { countDeclaredEntities } from './app-entities';
16
+ import { loadApp } from './app-load';
15
17
  // One declaration of where migrations live, and it belongs to the module that reads them —
16
18
  // `x db migrate` and this sidecar must never disagree about the directory they share.
17
19
  import { hashFileName, MIGRATIONS_DIR } from './migrations';
@@ -20,7 +22,48 @@ import type { Finding } from './output';
20
22
  export const DB_PACKAGE = join('packages', 'db');
21
23
  const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
22
24
 
23
- /** Content hash of the whole schema, order-independent per file path. */
25
+ /**
26
+ * Canonical JSON: object keys sorted, arrays in their own order. The registry's description is a
27
+ * BUILD INPUT committed to disk as a hash, so a field reordered inside `describe()` upstream would
28
+ * otherwise move every app's hash and report drift over a framework upgrade nobody made.
29
+ */
30
+ function canonicalJson(value: unknown): string {
31
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
32
+ if (typeof value === 'object' && value !== null) {
33
+ const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
34
+ a < b ? -1 : a > b ? 1 : 0,
35
+ );
36
+ return `{${entries.map(([key, held]) => `${JSON.stringify(key)}:${canonicalJson(held)}`).join(',')}}`;
37
+ }
38
+ // `undefined` has no JSON form and an optional field left unset must hash as absent, not throw.
39
+ return JSON.stringify(value) ?? 'null';
40
+ }
41
+
42
+ /**
43
+ * What the app's entities declare, as the registry describes them — the half `SCHEMA_GLOB` cannot
44
+ * see. `x new` puts an entity at `apps/web/app/<feature>/entity.ts` and `packages/db/src/schema.ts`
45
+ * merely re-exports it, so a column added there moved NO byte the glob reads: three generated
46
+ * entities and one migration reported clean, with every `.hash` sidecar identical. The registry is
47
+ * the same fact `x db gen` diffs (`describeEntities()`), so the check and the generator now read
48
+ * one schema instead of two.
49
+ *
50
+ * `loadApp` is what fills that registry, and it is called on every path rather than only where the
51
+ * caller happens to have loaded already: a hash computed against an EMPTY registry would differ
52
+ * from the one `x db gen` recorded with the app loaded, and every app would read as drifted.
53
+ * A module that will not import leaves the registry SHORT rather than raising — the stance
54
+ * `countDeclaredEntities` already documents — so `x doctor` still answers on the app it diagnoses.
55
+ */
56
+ async function declaredSchemaJson(root: string): Promise<string> {
57
+ await loadApp(root);
58
+ return canonicalJson(describeEntities());
59
+ }
60
+
61
+ /**
62
+ * Content hash of the whole schema: the entity registry first, then every non-test file under
63
+ * `packages/db/src`, order-independent per file path. Both halves, because they answer different
64
+ * questions — the registry is what reaches the database, and the glob is what catches a seed or a
65
+ * helper moving under it (which is what `reconcileSchemaHash` exists to re-record).
66
+ */
24
67
  export async function schemaHash(root: string): Promise<string> {
25
68
  const glob = new Bun.Glob(SCHEMA_GLOB);
26
69
  const paths: string[] = [];
@@ -29,6 +72,7 @@ export async function schemaHash(root: string): Promise<string> {
29
72
  }
30
73
  paths.sort();
31
74
  const hasher = new Bun.CryptoHasher('sha256');
75
+ hasher.update(await declaredSchemaJson(root));
32
76
  for (const path of paths) {
33
77
  hasher.update(path);
34
78
  hasher.update(await Bun.file(join(root, path)).text());
@@ -77,9 +121,10 @@ export interface HashReconciliation {
77
121
  }
78
122
 
79
123
  /**
80
- * Re-record the sidecar for a migration that is already the right one. `SCHEMA_GLOB` covers every
81
- * non-test file under `packages/db/src`, not only the ones that imply DDL, so editing a seed or a
82
- * helper moves the hash with no diff behind it — and `X_DB_DRIFT`'s `fix:` has to have somewhere to
124
+ * Re-record the sidecar for a migration that is already the right one. Neither half of the hash is
125
+ * DDL-only — `SCHEMA_GLOB` covers every non-test file under `packages/db/src`, and the registry
126
+ * carries invariants and tags a diff can leave empty — so an edit can move the hash with no
127
+ * statement behind it, and `X_DB_DRIFT`'s `fix:` has to have somewhere to
83
128
  * land or the instruction is unfollowable. The caller owes the proof that the DDL genuinely did not
84
129
  * move (`db-generate.ts` reaches this only on an empty diff off a fully loaded registry); this
85
130
  * function decides only whether a write is needed.
@@ -107,9 +152,9 @@ export type DeclaredEntityCount = () => Promise<number>;
107
152
  * Empty result = no drift. A missing db package is not drift (an app may have no database yet);
108
153
  * a schema with no migration at all is — *provided* the app declares an entity for one to record.
109
154
  *
110
- * The entity count is read lazily and ONLY in that first branch, so an app past its first migration
111
- * pays nothing for it: every other path answers from file hashes alone, with no app load and no
112
- * database, which is what lets the gate run this in a CI with neither.
155
+ * The entity count is read lazily and ONLY in that first branch; the hash itself loads the app on
156
+ * every path, because the registry is half of what it covers. Still no database anywhere here,
157
+ * which is what lets the gate run this in a CI with nothing listening.
113
158
  */
114
159
  export async function checkSourceDrift(
115
160
  root: string,
@@ -56,6 +56,10 @@ export const CLI_OWNED_ERROR_CODES = [
56
56
  'X_ROLE_UNKNOWN',
57
57
  'X_PORT_INVALID',
58
58
  'X_DEV_ALREADY_RUNNING',
59
+ // The OTHER thing the preflight can find, and it was reported as the one above: an unreadable
60
+ // lock this process could not remove made `DevAlreadyRunningError` name THIS pid as the holder,
61
+ // so the remedy printed was `kill <self>` — unrunnable, and a cause that was simply untrue.
62
+ 'X_DEV_LOCK_UNREADABLE',
59
63
  // The boot's own consistency check. `startServices` captures the drivers it built, and
60
64
  // `loadApp` runs AFTER it — so an app module calling `setJobDriver(theirs)` moved the ambient
61
65
  // slot and left the captured object alone: every `handle.enqueue()` went to their queue while
@@ -177,6 +181,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
177
181
  X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
178
182
  X_PORT_INVALID: 'PORT is not a TCP port number',
179
183
  X_DEV_ALREADY_RUNNING: 'another x dev already owns this checkout',
184
+ X_DEV_LOCK_UNREADABLE: 'the dev lock file cannot be read or removed',
180
185
  X_RUNTIME_DRIVER_SPLIT: 'the ambient driver is not the one this process serves',
181
186
  X_GENERATE_CONFLICT: 'a generator would overwrite a file',
182
187
  X_PORT_IN_USE: 'the dev port is taken',