@ultimat3/cli 1.2.0 → 3.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 (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +14 -8
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
package/src/dev-roles.ts CHANGED
@@ -8,28 +8,28 @@
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 } from '@ultimat3/http';
12
- import { createServer, defineHttpConfig } from '@ultimat3/http';
13
- import type { Scheduler, Worker } from '@ultimat3/jobs';
14
- import { createScheduler, createWorker } from '@ultimat3/jobs';
15
- import { listQueries } from '@ultimat3/query';
11
+ import type { Route, ServerHandle, ServerHooks } from '@ultimat3/http';
12
+ import { configuredAuthenticator, createServer, defineHttpConfig } from '@ultimat3/http';
13
+ import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
16
14
  import {
17
- ChannelHub,
18
- createSyncNode,
19
- LiveQueryRegistry,
20
- listenSyncNode,
21
- liveQueryDefinition,
22
- PresenceRegistry,
23
- RingChangeBuffer,
24
- SocketRegistry,
25
- } from '@ultimat3/realtime';
15
+ createOutboxRelay,
16
+ createPgLeaseLeader,
17
+ createScheduler,
18
+ createWorker,
19
+ jobDriver,
20
+ pgSchedulerState,
21
+ } from '@ultimat3/jobs';
26
22
  import { devHooks } from './dev-hooks';
23
+ import { pgExecutorFor } from './dev-queue';
27
24
  import type { RunningReplicator } from './dev-replicator';
28
25
  import { startReplicator } from './dev-replicator';
29
26
  import type { RunningServices } from './dev-runtime';
30
27
  import type { Env } from './dev-services';
31
- import { BadFlagError } from './errors';
28
+ import { startSync } from './dev-sync';
29
+ import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
32
30
  import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
31
+ import type { RuntimeOverrides } from './runtime-overrides';
32
+ import { inlineStyleSources } from './style-csp';
33
33
 
34
34
  /** The roles `x dev` starts when `--role` names none, in boot order. */
35
35
  export const DEV_ROLES: readonly Role[] = ['web', 'sync', 'worker', 'scheduler'];
@@ -58,12 +58,36 @@ export interface StartRolesOptions {
58
58
  * probe, which is the same failure in four costumes.
59
59
  */
60
60
  readonly http?: WebBinding;
61
+ /**
62
+ * The app's `auth.signInPath`. Threaded rather than read from the config here because
63
+ * `startRoles` takes plain values — a test starts a web role with no `app.config.ts` at all.
64
+ */
65
+ readonly signInPath?: string | null;
66
+ /**
67
+ * Inline `<style>` bodies this process serves that the app's own surfaces do not account for —
68
+ * `/_x`'s shell. The surfaces themselves are read from the stylesheet registry here rather than
69
+ * passed, so no caller of `startRoles` can ship a web server whose CSP blocks the pages it
70
+ * serves: that policy is what rendered every deployed app completely unstyled.
71
+ */
72
+ readonly inlineStyles?: readonly string[];
73
+ /**
74
+ * Non-fatal findings the browser overlay shows next to an error, for the request being answered.
75
+ * Only `x dev` supplies one — `serve.ts` boots through this same function and omits it, so a
76
+ * production process never has a diagnostic to call (axiom 6).
77
+ */
78
+ readonly devNotices?: ServerHooks['devNotices'];
61
79
  /**
62
80
  * Where the scrape listener binds. Defaults to `DEFAULT_METRICS_PORT`, except when `port` is 0
63
81
  * — a caller asking the kernel for an ephemeral HTTP port is a test, and a test that grabbed
64
82
  * 9090 would fail the next one to run beside it.
65
83
  */
66
84
  readonly metricsPort?: number;
85
+ /**
86
+ * What the host substituted for a boot decision. Read here for the three seams that are not
87
+ * services — the rate-limit store, the middleware chain and the sync authenticator — while the
88
+ * drivers themselves arrive already resolved on `runtime`.
89
+ */
90
+ readonly overrides?: RuntimeOverrides;
67
91
  }
68
92
 
69
93
  export interface WebBinding {
@@ -123,91 +147,120 @@ export function selectRoles(flag: string | undefined): readonly Role[] {
123
147
  return SELECTABLE_ROLES.filter((role) => selected.includes(role));
124
148
  }
125
149
 
150
+ /**
151
+ * A server whose route table demands an identity it has no way to resolve.
152
+ *
153
+ * `hooks.authenticate` is the only place an actor can come from, and `devHooks()` spreads nothing
154
+ * when the app never called `configureAuthenticator()`. So a process in that state boots clean,
155
+ * reports healthy, and refuses every valid session on every `auth: 'required'` route — which is
156
+ * exactly what the demo app did: sign-in issued a real cookie and the next page still said 401,
157
+ * while four unit tests over the app's own resolver stayed green because each installed a viewer
158
+ * by hand.
159
+ *
160
+ * A warning and not a throw, deliberately: `x new` scaffolds guarded routes before it scaffolds an
161
+ * authenticator, so an app in the minutes between the two is incomplete, not broken. It is loud,
162
+ * it names the code, and it prints the call that fixes it.
163
+ */
164
+ function warnIfUnauthenticatable(routes: readonly Route[]): void {
165
+ if (configuredAuthenticator() !== undefined) return;
166
+ const guarded = routes.filter((route) => route.meta.auth === 'required');
167
+ if (guarded.length === 0) return;
168
+ logger.warn(
169
+ `X_CONFIG_INVALID: ${guarded.length} route(s) declare auth: 'required' and no authenticator is configured, so every request is anonymous and each of them refuses every session — fix: call configureAuthenticator() at module scope in a file under apps/*/, e.g. configureAuthenticator((request) => viewerFor(request.header('cookie')))`,
170
+ );
171
+ }
172
+
173
+ /**
174
+ * How many proxies append to `x-forwarded-for` between the client and this process, or `null`
175
+ * when nothing in front of it is trusted.
176
+ *
177
+ * Read from the environment and not from `app.config.ts`, for the reason `PORT` and `ROLE` are: it
178
+ * is a fact about the DEPLOYMENT — one image runs behind an ingress in one cluster and behind
179
+ * nothing on a laptop — and an app that hardcoded it would be wrong in one of the two. `x dev`
180
+ * sets neither and gets `trustProxy: false`, which is correct: there is no proxy.
181
+ *
182
+ * Without this seam a container behind an ingress reads `ctx.ip` as the ingress's own socket
183
+ * address on every request, so the rate limiter keys the entire fleet's anonymous traffic into ONE
184
+ * bucket and a single scanner 429s every real signup.
185
+ */
186
+ export function trustedHopsFromEnv(env: Env): number | null {
187
+ const raw = env['TRUSTED_PROXY_HOPS']?.trim();
188
+ if (raw === undefined || raw === '') return null;
189
+ const hops = Number(raw);
190
+ // A malformed count is refused rather than defaulted: reading the header at the wrong index is
191
+ // trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
192
+ if (!Number.isInteger(hops) || hops < 1 || hops > 16) {
193
+ throw new PortInvalidError({ value: raw, name: 'TRUSTED_PROXY_HOPS' });
194
+ }
195
+ return hops;
196
+ }
197
+
126
198
  function startWeb(options: StartRolesOptions): ServerHandle {
199
+ warnIfUnauthenticatable(options.routes);
127
200
  const binding = options.http ?? DEV_BINDING;
201
+ const hops = trustedHopsFromEnv(options.env);
202
+ const store = options.overrides?.rateLimitStore;
128
203
  return createServer({
129
204
  routes: options.routes,
130
205
  role: 'web',
131
- hooks: devHooks(),
206
+ hooks: devHooks(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
207
+ // Both seams `createServer` already had and `startRoles` passed neither of, so an app's own
208
+ // middleware could not reach the pipeline any process the framework boots actually runs.
209
+ ...(options.overrides?.middleware === undefined
210
+ ? {}
211
+ : { middleware: options.overrides.middleware }),
212
+ ...(store === undefined ? {} : { rateLimitStore: store }),
132
213
  config: defineHttpConfig({
133
214
  port: options.port,
134
215
  dev: binding.dev,
135
216
  buildId: options.buildId,
136
217
  hostname: binding.hostname,
218
+ signInPath: options.signInPath ?? null,
219
+ // One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
220
+ ...(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.
227
+ rateLimit: { scope: store?.scope ?? 'process' },
228
+ // Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
229
+ // nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
230
+ // function of that body. Read after `loadApp` — importing the app IS what registered them.
231
+ security: {
232
+ csp: { extend: { 'style-src': inlineStyleSources(options.inlineStyles ?? []) } },
233
+ },
137
234
  }),
138
235
  }).start();
139
236
  }
140
237
 
141
238
  /**
142
- * Every read the app declared `live: true` becomes a subscribable query on this node, through
143
- * `@ultimat3/realtime`'s own bridge. A registry with nothing in it answers every live `subscribe`
144
- * with "no live query registered", which is a working socket serving no reads — and it is what
145
- * kept the row gate that decides per subscriber from ever running outside a unit test.
239
+ * The one moment the boot can still see both answers.
146
240
  *
147
- * The context is the node's, and it carries no actor: it supplies the services and the clock the
148
- * shared read needs, never an authority. Who may subscribe, and which rows they see, is decided
149
- * per socket at subscribe time and again for every row of every delivery.
150
- */
151
- function registerLiveQueries(options: StartRolesOptions): LiveQueryRegistry {
152
- const registry = new LiveQueryRegistry({
153
- source: new RingChangeBuffer(),
154
- // A withheld row is a metric, never a frame and never an error: telling a client "there is a
155
- // row you may not see" is the leak the gate exists to prevent.
156
- onRowDenied: (event) => logger.debug('live.rows_denied', { ...event }),
157
- });
158
- const ctx = createContext({ role: 'sync', buildId: options.buildId });
159
- for (const target of listQueries()) {
160
- if (target.isLive) registry.register(liveQueryDefinition(target, { ctx }));
161
- }
162
- return registry;
163
- }
164
-
165
- /**
166
- * The sync role owns its own socket: websockets and the request pipeline drain differently.
241
+ * `startServices` captures the drivers it built; `loadApp` imports the app's modules after it, and
242
+ * a module calling `setJobDriver()` at import time moves the ambient slot and leaves the capture
243
+ * alone. From here on the two are indistinguishable at every call site: `handle.enqueue()` reads
244
+ * the ambient one, `createWorker` claims from the captured one, and `/_x` reads the ambient one —
245
+ * so the dashboard agrees with the enqueue side and disagrees with reality.
167
246
  *
168
- * Port 0 is passed straight through rather than incremented — `+ 1` would ask the kernel for
169
- * port 1 instead of an ephemeral one — and the reported url is the listener's own bound address,
170
- * never a string built from the port that was requested.
247
+ * Refused, not reconciled. Reading through the accessor would make the split invisible instead of
248
+ * impossible, and the app would still have installed a driver the boot never saw — no outbox store
249
+ * bound to it, no relay draining it. The fix line names the field that does work.
171
250
  */
172
- async function startSync(
173
- options: StartRolesOptions,
174
- ): Promise<{ url: string; stop: () => Promise<void> }> {
175
- const sockets = new SocketRegistry();
176
- const hub = new ChannelHub({ transport: options.runtime.transport, sockets });
177
- const node = createSyncNode({
178
- hub,
179
- registry: registerLiveQueries(options),
180
- transport: options.runtime.transport,
181
- buildId: options.buildId,
182
- sockets,
183
- // Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
184
- // list at all — the KV bucket the transport just created would hold nothing and every `sync`
185
- // container would run a presence-less protocol. It reads and writes `transport.shared`, so it
186
- // is exactly as multi-node as the transport behind it: in-process here, the bucket under NATS.
187
- presence: new PresenceRegistry({
188
- transport: options.runtime.transport,
189
- hub,
190
- ttlMs: options.runtime.presenceTtlMs,
191
- }),
251
+ function assertOneJobDriver(runtime: RunningServices): void {
252
+ const ambient = jobDriver();
253
+ if (ambient === undefined || ambient === runtime.jobs) return;
254
+ throw new RuntimeDriverSplitError({
255
+ driver: 'jobs',
256
+ ambient: ambient.name,
257
+ captured: runtime.jobs.name,
192
258
  });
193
- await node.start();
194
- try {
195
- const listener = listenSyncNode(node, { port: options.port === 0 ? 0 : options.port + 1 });
196
- return {
197
- url: listener.url,
198
- stop: async () => {
199
- listener.stop();
200
- await node.stop();
201
- },
202
- };
203
- } catch (error) {
204
- await node.stop();
205
- throw error;
206
- }
207
259
  }
208
260
 
209
261
  export async function startRoles(options: StartRolesOptions): Promise<RunningRoles> {
210
262
  const selected = options.roles;
263
+ assertOneJobDriver(options.runtime);
211
264
  // Roles bind sockets in order, so a role that fails to start has to release the ones before it.
212
265
  // Without this a failed `sync` leaves the web server bound and unreachable by any caller.
213
266
  const started: (() => Promise<void>)[] = [];
@@ -236,8 +289,39 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
236
289
  worker?.start();
237
290
  if (worker !== null) started.push(() => worker.stop('x dev stopped'));
238
291
 
292
+ // The half of the transactional outbox that makes a staged row a running job. Without it
293
+ // `handle.enqueue()` inside a transaction writes to `x_outbox` and nothing ever reads it back
294
+ // — every enqueue in a request handler silently becomes a job that never runs.
295
+ //
296
+ // On `worker`, and on `worker` alone: it is the role that exists wherever jobs run at all, and
297
+ // a relay is safe to duplicate — the claim is a LEASE taken in the statement that locks the
298
+ // row, so two relays never hold one batch — but pointless to spread. The idempotency key is
299
+ // not the reason and never was: its conflict target is a partial index over live states, so it
300
+ // collapses a repeat only while the first job is still live. A deployment with no `worker` has
301
+ // no one to run the jobs either way.
302
+ const relay: OutboxRelay | null = selected.includes('worker')
303
+ ? createOutboxRelay({ store: options.runtime.outbox, driver: options.runtime.jobs })
304
+ : null;
305
+ relay?.start();
306
+ // Returned, not called-and-discarded: `stop()` waits out the pass in flight, and an unawaited
307
+ // one hands the failure rollback the same window a dropped `await` gives the teardown below.
308
+ if (relay !== null) started.push(() => relay.stop());
309
+
310
+ // `state` and `leader`, not the defaults. `createMemorySchedulerState` forgets every watermark
311
+ // on restart, so a rolling deploy re-fires or skips whatever was due across it, and
312
+ // `soleLeader()` makes every replica the leader — three `scheduler` pods, three of every task.
313
+ //
314
+ // `createPgLeaseLeader` and NOT `createPgLeader`: the latter's `pg_try_advisory_lock` is
315
+ // SESSION-scoped, and the session ends the moment the connection goes back to the pool, so
316
+ // every node reads itself as leader anyway. An expiring row is correct on the executor this
317
+ // package is actually handed.
318
+ const executor = pgExecutorFor(options.runtime.db);
239
319
  const scheduler = selected.includes('scheduler')
240
- ? createScheduler({ driver: options.runtime.jobs })
320
+ ? createScheduler({
321
+ driver: options.runtime.jobs,
322
+ state: pgSchedulerState(executor),
323
+ leader: createPgLeaseLeader({ executor }),
324
+ })
241
325
  : null;
242
326
  scheduler?.start();
243
327
  if (scheduler !== null) started.push(() => scheduler.stop());
@@ -267,6 +351,11 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
267
351
  // Reverse boot order, so the slot is released before the bus it published to closes.
268
352
  await replicator?.stop();
269
353
  await scheduler?.stop();
354
+ // Before the worker, so nothing publishes into a queue whose consumer has already gone —
355
+ // and AWAITED, because a pass is a `driver.enqueue` followed by a `markPublished`. Dropped,
356
+ // this returns between the two and the lines below close the pool under the row it was
357
+ // about to mark: re-published next boot at best, a rejection against a closed pool at worst.
358
+ await relay?.stop();
270
359
  await worker?.stop('x dev stopped');
271
360
  await sync?.stop();
272
361
  await server?.stop();
@@ -4,32 +4,36 @@
4
4
  // boot installs, only backed by embedded drivers.
5
5
 
6
6
  import { mkdirSync } from 'node:fs';
7
- import { join } from 'node:path';
8
7
  import type { PurgeDriver } from '@ultimat3/cache';
9
- import {
10
- createCdnTier,
11
- isNoopPurgeDriver,
12
- registerTier,
13
- resetTiers,
14
- selectPurgeDriver,
15
- } from '@ultimat3/cache';
16
- import type { EventBus, JobDriver } from '@ultimat3/jobs';
17
- import { createMemoryEventBus, setEventBus } from '@ultimat3/jobs';
8
+ import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
9
+ import { isLocal, resolveEnvironment } from '@ultimat3/core';
10
+ import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
18
11
  import type { MailDriver } from '@ultimat3/mail';
19
- import { isMemoryDriver, resetMailDriver, selectMailDriver, setMailDriver } from '@ultimat3/mail';
12
+ import {
13
+ isMemoryDriver,
14
+ isUnconfiguredDriver,
15
+ resetMailDriver,
16
+ selectMailDriver,
17
+ setMailDriver,
18
+ } from '@ultimat3/mail';
20
19
  import type { Transport, TransportSelection } from '@ultimat3/realtime';
21
20
  import { selectTransport } from '@ultimat3/realtime';
22
21
  import type { Storage } from '@ultimat3/storage';
23
- import { defineStorage, localDriver } from '@ultimat3/storage';
22
+ import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
23
+ import { startCacheTiers } from './dev-cache';
24
24
  import type { DevDbClient } from './dev-queue';
25
25
  import { startQueue } from './dev-queue';
26
26
  import type { DevServices, Env } from './dev-services';
27
+ import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
27
28
  import { msg } from './messages';
29
+ import type { RuntimeOverrides } from './runtime-overrides';
28
30
 
29
31
  export interface RunningServices {
30
32
  readonly services: DevServices;
31
33
  readonly db: DevDbClient;
32
34
  readonly jobs: JobDriver;
35
+ /** The `x_outbox` store behind `handle.enqueue()`. A role starts the relay that drains it. */
36
+ readonly outbox: OutboxStore;
33
37
  readonly events: EventBus;
34
38
  readonly transport: Transport;
35
39
  readonly storage: Storage;
@@ -62,6 +66,10 @@ export interface RunningServices {
62
66
  * the human half.
63
67
  */
64
68
  export function describeMail(runtime: RunningServices): string {
69
+ // Three arms, not two. A deployment with no credential outside development installs a driver that
70
+ // REFUSES every send, and reporting that as `external` would name it as a transport that delivers
71
+ // — the same lie the memory driver told when it answered `accepted` for mail nobody received.
72
+ if (isUnconfiguredDriver(runtime.mail)) return `mail=refused(${runtime.mailDetail})`;
65
73
  return isMemoryDriver(runtime.mail)
66
74
  ? 'mail=embedded'
67
75
  : `mail=external(${runtime.mail.name} via ${runtime.mailDetail})`;
@@ -84,6 +92,8 @@ export function describeCdn(runtime: RunningServices): string {
84
92
  * value stays where `--json` can depend on it.
85
93
  */
86
94
  export function mailLabel(runtime: RunningServices): string {
95
+ if (isUnconfiguredDriver(runtime.mail))
96
+ return msg('cli.dev.mail.refused', { detail: runtime.mailDetail });
87
97
  return isMemoryDriver(runtime.mail)
88
98
  ? msg('cli.dev.mail.embedded')
89
99
  : msg('cli.dev.mail.external', { driver: runtime.mail.name, detail: runtime.mailDetail });
@@ -98,13 +108,71 @@ export function cdnLabel(runtime: RunningServices): string {
98
108
 
99
109
  const FILE_SCHEME = 'file://';
100
110
 
101
- function startStorage(services: DevServices): Storage {
111
+ /**
112
+ * The storage disk this process will use.
113
+ *
114
+ * An `external` binding now reaches `s3Driver`. It used to fall through to a LOCAL directory —
115
+ * `S3_ENDPOINT` selected a different root and nothing else, so every deployment that configured
116
+ * object storage silently wrote to a container-local disk, and every upload was destroyed by the
117
+ * next restart while the configured bucket stayed empty. Nothing failed; the files just went
118
+ * nowhere.
119
+ *
120
+ * The embedded branch is also where a hardened container died. `mkdirSync` on a
121
+ * `readOnlyRootFilesystem` throws a bare `EROFS` from inside Bun's fs, with no code, no fix and no
122
+ * mention of storage — the demo CrashLooped 22 times on it. A read-only filesystem is a normal way
123
+ * to run a container, so this reports what to do instead of what went wrong.
124
+ */
125
+ export function startStorage(services: DevServices, env: Env, override?: Storage): Storage {
126
+ // First arm, not a branch beside the two below: a host that handed the boot a `Storage` has
127
+ // already answered "which disk", and re-deriving one from `S3_ENDPOINT` would be a second answer.
128
+ if (override !== undefined) return override;
102
129
  const binding = services.storage;
103
- const root =
104
- binding.mode === 'embedded'
105
- ? binding.url.slice(FILE_SCHEME.length)
106
- : join(services.stateDir, 'storage');
107
- mkdirSync(root, { recursive: true });
130
+
131
+ if (binding.mode === 'external') {
132
+ const bucket = env['S3_BUCKET'] ?? '';
133
+ if (bucket === '') {
134
+ throw new StorageUnwritableError(
135
+ `S3_ENDPOINT is set to ${binding.url} but S3_BUCKET is empty, so there is no disk to write to`,
136
+ 'set S3_BUCKET to the bucket name, or unset S3_ENDPOINT to use the embedded disk',
137
+ );
138
+ }
139
+ return defineStorage({
140
+ disks: {
141
+ object: s3Driver({
142
+ bucket,
143
+ endpoint: binding.url,
144
+ ...(env['S3_REGION'] === undefined ? {} : { region: env['S3_REGION'] }),
145
+ // MinIO needs path style; R2 and AWS do not. Declared, never sniffed from the endpoint.
146
+ forcePathStyle: env['S3_FORCE_PATH_STYLE'] === '1',
147
+ }),
148
+ },
149
+ default: 'object',
150
+ });
151
+ }
152
+
153
+ const root = binding.url.slice(FILE_SCHEME.length);
154
+ // The embedded disk is what this branch falls back to whenever `S3_ENDPOINT`/`S3_BUCKET` are
155
+ // unset — including in `production` and `staging`, where an unset `STORAGE_SIGNING_SECRET` means
156
+ // every signed upload grant is minted with the string published in this repo, and
157
+ // `acceptSignedUpload` trusts a signed constraint over the app's own `uploadPolicy`.
158
+ //
159
+ // `localDriver` refuses that at construction on its own (`X_ENV_MISSING`). Pre-empted here so
160
+ // the message names the STORAGE CHOICE and both ways out, rather than arriving as "a secret is
161
+ // missing" from a helper the operator never configured. Not an outright ban on the local disk in
162
+ // production: a single-node Compose deploy on a mounted volume WITH a real secret is a rung on
163
+ // the scale ladder, and refusing it would be a deploy-shape decision, not a security fix.
164
+ if (!isLocal() && usesDevStorageSecret()) {
165
+ throw new LocalDiskUnsafeError({ environment: resolveEnvironment(), root });
166
+ }
167
+ try {
168
+ mkdirSync(root, { recursive: true });
169
+ } catch (cause) {
170
+ const detail = cause instanceof Error ? cause.message : String(cause);
171
+ throw new StorageUnwritableError(
172
+ `the embedded storage disk needs ${root} and it could not be created: ${detail}`,
173
+ `mount a writable volume at ${root}, or set S3_ENDPOINT and S3_BUCKET to use object storage instead`,
174
+ );
175
+ }
108
176
  return defineStorage({ disks: { local: localDriver({ root }) }, default: 'local' });
109
177
  }
110
178
 
@@ -126,7 +194,11 @@ async function release(steps: readonly (() => void | Promise<void>)[]): Promise<
126
194
  return failures;
127
195
  }
128
196
 
129
- export async function startServices(services: DevServices, env: Env): Promise<RunningServices> {
197
+ export async function startServices(
198
+ services: DevServices,
199
+ env: Env,
200
+ overrides?: RuntimeOverrides,
201
+ ): Promise<RunningServices> {
130
202
  // Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does
131
203
  // not dial. A typo'd credential must fail on the spot rather than after PGlite has started and
132
204
  // been unwound again, and it must fail at boot rather than on the first mail nobody receives.
@@ -140,49 +212,54 @@ export async function startServices(services: DevServices, env: Env): Promise<Ru
140
212
  // `@ultimat3/realtime`'s decision, and it is the same call a `ROLE=sync` container makes, so this
141
213
  // process cannot resolve the bus differently from the container it stands in for.
142
214
  const bus: TransportSelection = selectTransport(env);
143
- const queue = await startQueue(services);
144
- const { db, jobs } = queue;
215
+ const queue = await startQueue(services, overrides);
216
+ const { db, jobs, outbox, events } = queue;
145
217
  // Boot is a sequence of external resources, and every step after the first can reject — the
146
218
  // queue is already up, so from here an unwind must release it exactly like everything after it.
147
219
  const started: (() => void | Promise<void>)[] = [() => queue.stop()];
148
220
  try {
149
- const events = createMemoryEventBus();
150
- setEventBus(events);
151
221
  // Dialled here rather than at selection: an unreachable bus must fail at `x dev`, not on the
152
222
  // first change nobody receives, and the socket is a resource the unwind below has to release.
153
- await bus.connect();
154
- started.push(() => bus.transport.close());
155
- const storage = startStorage(services);
223
+ // A supplied transport is already connected and is NOT closed here: whoever built it owns its
224
+ // socket, which is why the override skips both halves rather than only the dial.
225
+ let transport = overrides?.transport;
226
+ if (transport === undefined) {
227
+ await bus.connect();
228
+ started.push(() => bus.transport.close());
229
+ transport = bus.transport;
230
+ }
231
+ const storage = startStorage(services, env, overrides?.storage);
156
232
  // With no credential this is the memory driver: caught, not sent, so the `/_x` mail panel can
157
233
  // show what a template renders in every locale without a mailbox or a message escaping to a
158
234
  // real address. `SMTP_URL` or `RESEND_API_KEY` makes it a real transport instead — the same
159
235
  // "an unset variable means the embedded default" law the other three bindings follow.
160
- const mail = selection.driver;
236
+ const mail = overrides?.mail ?? selection.driver;
161
237
  setMailDriver(mail);
162
238
  started.push(() => resetMailDriver());
163
- // Registered only when a credential named a real edge. A noop tier would put a `cdn` line in
164
- // every invalidation report claiming keys an edge that does not exist had accepted — and the
165
- // `/_x` cache panel renders those reports, so the lie would be the thing an agent reads.
166
- // Released with `resetTiers()`, which drops the whole registry: this boot is the only thing
167
- // that registers one, and a tier left behind would purge for a process that has stopped.
168
- const purging = !isNoopPurgeDriver(cdn.driver);
169
- if (purging) {
170
- registerTier(createCdnTier({ purge: cdn.driver }));
171
- started.push(() => resetTiers());
172
- }
239
+ const purge = overrides?.purge ?? cdn.driver;
240
+ // Every tier this process reads through, plus the cross-instance invalidation hop, in one
241
+ // call. The CDN tier used to be the only one registered here — so `createRedisTier`,
242
+ // `createLruTier` and `createMemoTier` shipped with no caller at all and every replica
243
+ // recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
244
+ // this boot is the only thing that registers one, and a tier left behind would purge for a
245
+ // process that has stopped.
246
+ started.push(startCacheTiers({ env, purge, transport }));
173
247
 
174
248
  return {
175
249
  services,
176
250
  db,
177
251
  jobs,
252
+ outbox,
178
253
  events,
179
- transport: bus.transport,
254
+ transport,
180
255
  storage,
181
256
  mail,
182
257
  mailDetail: selection.detail,
183
- transportDetail: bus.detail,
258
+ // The env key that selected the bus — or the honest answer that no env key did, because a
259
+ // boot line reading `NATS_URL` over a transport the host handed in is a lie a script parses.
260
+ transportDetail: overrides?.transport === undefined ? bus.detail : 'runtime override',
184
261
  presenceTtlMs: bus.presenceTtlMs,
185
- purge: cdn.driver,
262
+ purge,
186
263
  purgeDetail: cdn.detail,
187
264
  // The same list the boot unwind uses, in the same reverse order, so a service added to the
188
265
  // boot is released by both paths — a second copy of these steps is how one of them came to
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { mkdirSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
+ import { safeUrlLabel } from './safe-url-label';
7
8
 
8
9
  export type ServiceMode = 'embedded' | 'external';
9
10
 
@@ -74,6 +75,20 @@ export function resolveServices(root: string, env: Env): DevServices {
74
75
  };
75
76
  }
76
77
 
78
+ /**
79
+ * The three service urls as a report may carry them, and the ONE place they become printable.
80
+ * `x dev --json` emitted `DATABASE_URL` and `NATS_URL` verbatim — passwords included — into a
81
+ * field that is printed to a terminal, piped into a log and scraped by a script, while the rule
82
+ * against it was already written three lines from the emitting code and applied only to mail and
83
+ * cdn. The bindings keep the real url because `dev-queue.ts` has to connect with it; only this
84
+ * projection is redacted, so a leak cannot come back as a caller forgetting to call a helper.
85
+ */
86
+ export const reportedUrls = (services: DevServices): Record<ServiceBinding['name'], string> => ({
87
+ db: safeUrlLabel(services.db.url, services.db.name),
88
+ events: safeUrlLabel(services.events.url, services.events.name),
89
+ storage: safeUrlLabel(services.storage.url, services.storage.name),
90
+ });
91
+
77
92
  export const describeServices = (services: DevServices): string =>
78
93
  [services.db, services.events, services.storage]
79
94
  .map((binding) => `${binding.name}=${binding.mode}`)