@ultimat3/cli 11.2.0 → 12.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.
@@ -27,6 +27,7 @@ import { SHOT_DIR } from './shot-server';
27
27
  export function islandBrowser(input: {
28
28
  readonly root: string;
29
29
  readonly executablePath?: string | undefined;
30
+ readonly cdpUrl?: string | undefined;
30
31
  }): IslandBrowser {
31
32
  const byViewport = new Map<string, Promise<ScrapeDriver>>();
32
33
  return (viewport: IslandViewport): Promise<ScrapeDriver> => {
@@ -36,6 +37,7 @@ export function islandBrowser(input: {
36
37
  const started = appBrowser({
37
38
  root: input.root,
38
39
  ...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
40
+ ...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
39
41
  viewport: { width: viewport.width, height: viewport.height },
40
42
  });
41
43
  byViewport.set(key, started);
@@ -86,6 +88,8 @@ export interface IslandShotInput {
86
88
  readonly timeoutMs: number;
87
89
  readonly extraHosts?: string | undefined;
88
90
  readonly executablePath?: string | undefined;
91
+ /** A provider's session, or a sidecar. One attach per viewport, memoised like a launch. */
92
+ readonly cdpUrl?: string | undefined;
89
93
  readonly boot: () => Promise<ShotServer>;
90
94
  /** Injected by a test, so the whole path is proved on a machine with no Chrome. */
91
95
  readonly driver?: IslandBrowser | undefined;
@@ -124,6 +128,7 @@ export async function islandShot(input: IslandShotInput): Promise<IslandArtifact
124
128
  islandBrowser({
125
129
  root: input.root,
126
130
  ...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
131
+ ...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
127
132
  }),
128
133
  boot: input.boot,
129
134
  settleMs: input.settleMs,
package/src/cmd-shot.ts CHANGED
@@ -11,7 +11,7 @@ import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
11
11
  import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
12
12
  import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
13
13
  import { requireAppRoot } from './app-root';
14
- import { appBrowser, browserBinaryExists, executablePathFrom } from './browser-launcher';
14
+ import { appBrowser } from './browser-launcher';
15
15
  import { islandShot, islandShotResult, refuseRouteWithIsland } from './cmd-shot-island';
16
16
  import type { CliCommand, CommandContext } from './command';
17
17
  import { BadFlagError, MissingPositionalError } from './errors';
@@ -19,6 +19,7 @@ import { intFlagOr, PORT_RANGE } from './flag-number';
19
19
  import type { CommandResult } from './output';
20
20
  import type { ParsedArgs } from './parse';
21
21
  import { flagBool, flagString } from './parse';
22
+ import { shotBrowserChoice } from './shot-browser';
22
23
  import type { BootDevServer, ShotServer } from './shot-server';
23
24
  import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
24
25
  import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
@@ -285,6 +286,11 @@ export const shotCommand: CliCommand = {
285
286
  { name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
286
287
  { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
287
288
  { name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
289
+ {
290
+ name: 'cdp-url',
291
+ type: 'string',
292
+ summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
293
+ },
288
294
  { name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
289
295
  // A FLAG on `x shot` and never a second command: photographing a route and photographing a
290
296
  // component are one job with two subjects, and a parallel command would be the second path
@@ -314,15 +320,14 @@ export const shotCommand: CliCommand = {
314
320
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
315
321
  const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
316
322
  const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
317
- const executablePath = executablePathFrom(flagString(ctx.args, 'browser'), ctx.env);
318
- if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
319
- throw new BadFlagError({
320
- flag: 'browser',
321
- command: 'shot',
322
- reason: `no executable at "${executablePath}"`,
323
- fix: 'x shot / --browser /usr/bin/chromium',
324
- });
325
- }
323
+ // Which browser this run gets — start one here, or attach to one somebody else is running.
324
+ // Decided by `shot-browser.ts` over plain inputs, and decided HERE, before a dev server or a
325
+ // provider session exists to pay for a typo.
326
+ const { cdpUrl, executablePath } = shotBrowserChoice({
327
+ cdpFlag: flagString(ctx.args, 'cdp-url'),
328
+ browserFlag: flagString(ctx.args, 'browser'),
329
+ env: ctx.env,
330
+ });
326
331
  const out = flagString(ctx.args, 'out');
327
332
  const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
328
333
  if (island !== undefined && island !== '') {
@@ -337,6 +342,7 @@ export const shotCommand: CliCommand = {
337
342
  settleMs,
338
343
  timeoutMs,
339
344
  ...(executablePath === undefined ? {} : { executablePath }),
345
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
340
346
  ...(flagString(ctx.args, 'allow-hosts') === undefined
341
347
  ? {}
342
348
  : { extraHosts: flagString(ctx.args, 'allow-hosts') }),
@@ -349,6 +355,7 @@ export const shotCommand: CliCommand = {
349
355
  const driver = await appBrowser({
350
356
  root,
351
357
  ...(executablePath === undefined ? {} : { executablePath }),
358
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
352
359
  });
353
360
  return shotResult(
354
361
  await runShot({
@@ -8,10 +8,13 @@
8
8
  * `package.json` inside a `catch` — and both sit in `loadCtsDefault`, which only runs while Babel
9
9
  * loads a `.cts` CONFIG FILE. `solid-loader.ts` passes `babelrc: false, configFile: false`, so no
10
10
  * config file is ever loaded and neither line is reachable at run time. The bundler walks them
11
- * anyway, and that is the whole failure: Bun 1.3 (what CI pins and what `docker/Dockerfile` builds
12
- * on) refuses the build with `Could not resolve: "@babel/preset-typescript/package.json"`, while
13
- * Bun 1.4 bundles the unresolvable `require` as a runtime throw — so one tree compiled on a laptop
14
- * and did not in CI.
11
+ * anyway, and that is the whole failure: Bun 1.3 refuses the build with
12
+ * `Could not resolve: "@babel/preset-typescript/package.json"`, while Bun 1.4 bundles the
13
+ * unresolvable `require` as a runtime throw — so one tree compiled on a laptop and did not in CI.
14
+ * That skew is closed `As of 2026-08-20`: CI pins `1.4.x` (`.github/actions/setup/action.yml`),
15
+ * `docker/Dockerfile` builds on `oven/bun:1.4-slim` and `scripts/setup.ts` holds contributors to
16
+ * 1.4.0, so every builder now takes the second branch. The external stays regardless — it is the
17
+ * `--compile` graph that must not reach an unresolvable `require`, on either Bun.
15
18
  *
16
19
  * Marking the dead specifier external rather than the two live ones: `serve.ts` calls
17
20
  * `buildIslands` on every boot, unconditionally, so a binary with `@babel/core` external is a
package/src/dev-queue.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type PostgresIdempotencyStore,
9
9
  postgresIdempotencyStore,
10
10
  resetIdempotency,
11
+ SQL_AUDIT_TABLE,
11
12
  SQL_IDEMPOTENCY_TABLE,
12
13
  setIdempotencyStore,
13
14
  } from '@ultimat3/action';
@@ -36,12 +37,19 @@ import {
36
37
  setJobDriver,
37
38
  setJobsFacade,
38
39
  } from '@ultimat3/jobs';
40
+ import { attachReplica, type ReplicaEnv, replicaUrlFor } from './dev-replica';
39
41
  import type { DevServices } from './dev-services';
40
42
  import type { RuntimeOverrides } from './runtime-overrides';
41
43
 
42
44
  /** Both embedded and external clients boot lazily and close explicitly. */
43
45
  export type DevDbClient = PgliteClient | PostgresClient;
44
46
 
47
+ /** The primary this boot owns, plus the standby pool it opened — `stop()` closes both. */
48
+ interface StartedDb {
49
+ readonly client: DevDbClient;
50
+ readonly replica: PostgresClient | undefined;
51
+ }
52
+
45
53
  export interface RunningQueue {
46
54
  readonly db: DevDbClient;
47
55
  readonly jobs: JobDriver;
@@ -62,7 +70,20 @@ export interface RunningQueue {
62
70
  stop(): Promise<void>;
63
71
  }
64
72
 
65
- function startDb(services: DevServices): DevDbClient {
73
+ /**
74
+ * The boot's own client, and the AMBIENT one, which are deliberately not always the same object.
75
+ *
76
+ * `setDbClient` receives the replicated pair when `DATABASE_REPLICA_URL` names a standby, so an
77
+ * app repository reading through `db()` inside an open `withReplicaReads` scope can be served by
78
+ * it. Everything this file does itself — `applySchema`, the `PgExecutor` behind the queue, the
79
+ * outbox, `ping()`, `close()` — keeps the PRIMARY: DDL, a claim and a migration are writes by
80
+ * definition, and routing one would be `25006` at best.
81
+ *
82
+ * Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
83
+ * it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
84
+ * so `DATABASE_REPLICA_URL` was read by no booted process at all.
85
+ */
86
+ function startDb(services: DevServices, env: ReplicaEnv = process.env): StartedDb {
66
87
  const binding = services.db;
67
88
  const client =
68
89
  binding.mode === 'embedded'
@@ -70,8 +91,9 @@ function startDb(services: DevServices): DevDbClient {
70
91
  // here is a second thing to keep right when the form changes.
71
92
  createPgliteClient({ dataDir: pgliteDataDir(binding.url) })
72
93
  : createPostgresClient({ url: binding.url });
73
- setDbClient(client);
74
- return client;
94
+ const attached = attachReplica(client, replicaUrlFor(binding, env));
95
+ setDbClient(attached.client);
96
+ return { client, replica: attached.replica };
75
97
  }
76
98
 
77
99
  /**
@@ -111,6 +133,13 @@ async function applySchema(client: DevDbClient): Promise<void> {
111
133
  for (const ddl of [
112
134
  SQL_JOBS_TABLE,
113
135
  SQL_IDEMPOTENCY_TABLE,
136
+ // The DDL only, and deliberately NO `setAuditSink` beside `setIdempotencyStore` below: there
137
+ // is no default audit sink on purpose, so `X_AUDIT_SINK_MISSING` keeps firing at boot for an
138
+ // app that declares `audit: true` and installs none. Applying the table without installing a
139
+ // sink is the same call `SQL_RATE_LIMIT_TABLE` already makes — one round trip at boot on a
140
+ // possibly-unused table, against `postgresAuditSink` failing its first write with
141
+ // `relation "x_audit" does not exist`.
142
+ SQL_AUDIT_TABLE,
114
143
  SQL_RATE_LIMIT_TABLE,
115
144
  SQL_AUTH_LIMIT_TABLES,
116
145
  ]) {
@@ -148,7 +177,11 @@ async function applySchema(client: DevDbClient): Promise<void> {
148
177
  * `startServices` runs before `loadApp`, so the store is in place before `registerAction`
149
178
  * evaluates a `scope: 'shared'` declaration against it.
150
179
  */
151
- async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Promise<RunningQueue> {
180
+ async function startJobs(
181
+ client: DevDbClient,
182
+ replica: PostgresClient | undefined,
183
+ overrides?: RuntimeOverrides,
184
+ ): Promise<RunningQueue> {
152
185
  await applySchema(client);
153
186
  const executor = pgExecutorFor(client);
154
187
  const driver = overrides?.jobs ?? createPgDriver({ executor });
@@ -171,7 +204,7 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
171
204
  outbox,
172
205
  events,
173
206
  idempotency,
174
- stop: () => releaseQueue(client, driver),
207
+ stop: () => releaseQueue(client, driver, replica),
175
208
  };
176
209
  }
177
210
 
@@ -184,7 +217,11 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
184
217
  * The facade goes with the driver for the same reason: an enqueue routed through a store bound to
185
218
  * a closed client is a staged row nothing will ever publish.
186
219
  */
187
- async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promise<void> {
220
+ async function releaseQueue(
221
+ db: DevDbClient,
222
+ jobs: JobDriver | undefined,
223
+ replica?: PostgresClient,
224
+ ): Promise<void> {
188
225
  // The idempotency store goes back to the memory default for the same reason the facade does: it
189
226
  // holds this client, and the next command in this process would reserve keys over a closed one.
190
227
  resetIdempotency();
@@ -193,6 +230,9 @@ async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promi
193
230
  setDbClient(undefined);
194
231
  await jobs?.close?.();
195
232
  await db.close();
233
+ // After the primary, and never instead of it: a standby pool left open is a connection slot on
234
+ // the other server that nothing in this process can reach again.
235
+ await replica?.close();
196
236
  }
197
237
 
198
238
  /**
@@ -205,18 +245,18 @@ export async function startQueue(
205
245
  services: DevServices,
206
246
  overrides?: RuntimeOverrides,
207
247
  ): Promise<RunningQueue> {
208
- const db = startDb(services);
248
+ const { client: db, replica } = startDb(services);
209
249
  try {
210
250
  // Pay the Postgres boot here, so the first request is not the slow one and a broken database
211
251
  // fails at boot rather than on some later query.
212
252
  await db.ping();
213
- return await startJobs(db, overrides);
253
+ return await startJobs(db, replica, overrides);
214
254
  } catch (error) {
215
255
  // `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
216
256
  // the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
217
257
  // release them. The rejection that started the unwind is the one worth reporting.
218
258
  try {
219
- await releaseQueue(db, undefined);
259
+ await releaseQueue(db, undefined, replica);
220
260
  } catch {
221
261
  // Cleanup noise never replaces the boot failure.
222
262
  }
@@ -0,0 +1,99 @@
1
+ // Read-replica routing, WIRED. `@ultimat3/db` ships both halves and a booted process reached
2
+ // neither, so the capability was shipped and unactivated — the "declared and never wired" class
3
+ // this release exists to eliminate.
4
+ //
5
+ // Two things had to be false for it to route, and both were:
6
+ //
7
+ // 1. `defaultClient()` is the one place db composes `replicatedClient(primary, replica)` from
8
+ // `DATABASE_REPLICA_URL`, and it runs only from `baseClient()` — "the client an app installed
9
+ // none for". Every process the framework boots installs one: `dev-queue.ts` calls
10
+ // `setDbClient(createPgliteClient(…) | createPostgresClient({ url }))`, so `defaultClient()` was
11
+ // unreachable from `x dev`, from `apps/web/server.ts` and from every container role.
12
+ // 2. Routing needs an OPEN scope as well as a configured replica, and nothing opened one.
13
+ //
14
+ // This file answers both from the boot, which is the only tier that may know about a request AND
15
+ // about a pool. It is deliberately NOT a change to `@ultimat3/http`'s pipeline: that would make the
16
+ // HTTP tier depend on `@ultimat3/db` — legal downward, and still a package that would then know
17
+ // what a database is.
18
+
19
+ import type { DbClient, PostgresClient } from '@ultimat3/db';
20
+ import {
21
+ createPostgresClient,
22
+ REPLICA_URL_ENV,
23
+ replicatedClient,
24
+ withReplicaReads,
25
+ } from '@ultimat3/db';
26
+ import type { Middleware } from '@ultimat3/http';
27
+ import type { ServiceBinding } from './dev-services';
28
+ import type { RuntimeOverrides } from './runtime-overrides';
29
+
30
+ export type ReplicaEnv = Readonly<Record<string, string | undefined>>;
31
+
32
+ /**
33
+ * The replica url this boot should use, or `undefined`. Two conditions, and the second is the one
34
+ * a homework app depends on: an EMBEDDED database is PGlite in this process, which has no standby
35
+ * and never will, so a `DATABASE_REPLICA_URL` left over in a shell must not silently open a second
36
+ * pool beside it.
37
+ */
38
+ export function replicaUrlFor(binding: ServiceBinding, env: ReplicaEnv): string | undefined {
39
+ if (binding.mode !== 'external') return undefined;
40
+ const url = env[REPLICA_URL_ENV];
41
+ return url === undefined || url.trim() === '' ? undefined : url;
42
+ }
43
+
44
+ /** What a boot has to hold on to: the ambient client, and the pool `stop()` must close. */
45
+ export interface ReplicaAttachment {
46
+ /** What `setDbClient()` receives — the pair when one is configured, the primary otherwise. */
47
+ readonly client: DbClient;
48
+ /** The standby pool this boot opened, or nothing. `ReplicatedClient` has no `close()`. */
49
+ readonly replica: PostgresClient | undefined;
50
+ }
51
+
52
+ /**
53
+ * The primary as it is, or the primary with a standby behind it. Composed here rather than by
54
+ * calling `defaultClient()`, because that function builds its own primary from `DATABASE_URL` and
55
+ * this boot has already RESOLVED which database it is talking to (`resolveServices`) — asking the
56
+ * environment a second question is how a process ends up with two answers to "which database is
57
+ * this". The pieces are db's own, so the routing rule is still stated in one place.
58
+ */
59
+ export function attachReplica(
60
+ primary: DbClient,
61
+ replicaUrl: string | undefined,
62
+ ): ReplicaAttachment {
63
+ if (replicaUrl === undefined) return { client: primary, replica: undefined };
64
+ const replica = createPostgresClient({ url: replicaUrl, applicationName: 'ultimate-replica' });
65
+ return { client: replicatedClient(primary, replica), replica };
66
+ }
67
+
68
+ /**
69
+ * The scope, opened once per request, OUTSIDE the handler — so a write early in a request pins
70
+ * every later read in it to the primary, which is what read-your-writes means. `compose()` wraps
71
+ * each route handler, and every read and write a request makes happens inside one.
72
+ *
73
+ * EMPTY when no replica is configured, and that is the whole cost argument: a homework app
74
+ * installs no middleware, allocates no scope object and enters no `AsyncLocalStorage` run per
75
+ * request. With one configured, the list is a single frame.
76
+ */
77
+ export function replicaMiddleware(binding: ServiceBinding, env: ReplicaEnv): readonly Middleware[] {
78
+ if (replicaUrlFor(binding, env) === undefined) return [];
79
+ return [(request, ctx, next) => withReplicaReads(() => next(request, ctx))];
80
+ }
81
+
82
+ /**
83
+ * The boot's `RuntimeOverrides` with the scope middleware laid in FRONT of whatever a host
84
+ * supplied — `compose([a, b])` runs `a` outermost, and the scope must open outside every read and
85
+ * write the request makes or a write early in it cannot pin the reads after it.
86
+ *
87
+ * Returns the caller's own value UNCHANGED when no replica is configured, which is what keeps a
88
+ * homework app from paying for this: no key added, no empty array, nothing for `startRoles` to
89
+ * forward.
90
+ */
91
+ export function replicaOverrides(
92
+ overrides: RuntimeOverrides | undefined,
93
+ binding: ServiceBinding,
94
+ env: ReplicaEnv,
95
+ ): RuntimeOverrides | undefined {
96
+ const middleware = replicaMiddleware(binding, env);
97
+ if (middleware.length === 0) return overrides;
98
+ return { ...overrides, middleware: [...middleware, ...(overrides?.middleware ?? [])] };
99
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { noopPurgeDriver } from '@ultimat3/cache';
9
9
  import { resetLifecycle } from '@ultimat3/core';
10
+ import { resetHttpConfig } from '@ultimat3/http';
10
11
  import {
11
12
  createMemoryDriver,
12
13
  createMemoryEventBus,
@@ -64,4 +65,10 @@ export function resetDevRolesState(): void {
64
65
  resetJobsFacade();
65
66
  resetTasks();
66
67
  resetLifecycle();
68
+ // `configureHttp()` is a process-global registration made at MODULE scope, so one file that
69
+ // loads an app leaves that app's CORS origins, body limit and buckets standing for every later
70
+ // file in the same `bun test` process — and a scaffolded app now ships such a module
71
+ // (`apps/web/app/http.ts`). Nothing is broken today; this is the same rule the four resets above
72
+ // already follow, applied to the fifth global before it is the one that costs an afternoon.
73
+ resetHttpConfig();
67
74
  }
package/src/dev-roles.ts CHANGED
@@ -9,7 +9,13 @@
9
9
  import type { Role } from '@ultimat3/core';
10
10
  import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
11
11
  import type { RateLimitStore, Route, ServerHandle, ServerHooks } from '@ultimat3/http';
12
- import { configuredAuthenticator, createServer, defineHttpConfig } from '@ultimat3/http';
12
+ import {
13
+ configuredAuthenticator,
14
+ configuredHttp,
15
+ createServer,
16
+ defineHttpConfig,
17
+ mergeHttpConfig,
18
+ } from '@ultimat3/http';
13
19
  import type { OutboxRelay, Scheduler, Worker } from '@ultimat3/jobs';
14
20
  import {
15
21
  createOutboxRelay,
@@ -257,37 +263,45 @@ function startWeb(options: StartRolesOptions): ServerHandle {
257
263
  ? {}
258
264
  : { middleware: options.overrides.middleware }),
259
265
  ...(store === undefined ? {} : { rateLimitStore: store }),
260
- config: defineHttpConfig({
261
- port: options.port,
262
- dev: binding.dev,
263
- buildId: options.buildId,
264
- hostname: binding.hostname,
265
- signInPath: options.signInPath ?? null,
266
- // One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
267
- ...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
268
- // `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error, and it
269
- // is DERIVED from the store rather than hardcoded — a literal here would be a second
270
- // declaration quietly contradicting the object beside it, and `assertRateLimitScope` holds
271
- // the two halves together. It answered `'process'` on every real boot until `startServices`
272
- // resolved a store, so the shipped chart's three `web` replicas enforced `login: { limit: 5 }`
273
- // as fifteen attempts, with `x verify` green.
274
- rateLimit: { scope: store?.scope ?? 'process' },
275
- // Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
276
- // nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
277
- // function of that body. Read after `loadApp` — importing the app IS what registered them.
278
- // BOTH directives, and the script half is the one that was missing: the hydration runtime
279
- // is emitted inline in every document that carries an island, so `script-src 'self'` meant
280
- // no island booted anywhere the policy is enforced — which is every container, and never
281
- // `x dev`, where it is report-only.
282
- security: {
283
- csp: {
284
- extend: {
285
- 'style-src': inlineStyleSources(options.inlineStyles ?? []),
286
- 'script-src': inlineScriptSources(),
266
+ // The app's own declaration UNDERNEATH, this boot's facts on top. Without the first half the
267
+ // entire HTTP tuning surface was unreachable from a shipped app — this literal was its only
268
+ // construction, so `cors.origins` stayed `[]` in every deployment (no cross-origin call could
269
+ // ever succeed), `bodyLimitBytes` stayed 1 MiB and `requestTimeoutMs` 30s for a bank and a
270
+ // blog alike. The ORDER is not a preference: `buildId`, the port, the CSP hashes of what this
271
+ // process emits and the scope of the store it installed are facts only the boot has.
272
+ config: defineHttpConfig(
273
+ mergeHttpConfig(configuredHttp(), {
274
+ port: options.port,
275
+ dev: binding.dev,
276
+ buildId: options.buildId,
277
+ hostname: binding.hostname,
278
+ signInPath: options.signInPath ?? null,
279
+ // One declaration, never half of one: `defineHttpConfig` refuses `trustProxy` without hops.
280
+ ...(hops === null ? {} : { trustProxy: true, trustedProxyHops: hops }),
281
+ // `scope` is mandatory since @ultimat3/http made an undeclared limiter a boot error, and it
282
+ // is DERIVED from the store rather than hardcoded — a literal here would be a second
283
+ // declaration quietly contradicting the object beside it, and `assertRateLimitScope` holds
284
+ // the two halves together. It answered `'process'` on every real boot until `startServices`
285
+ // resolved a store, so the shipped chart's three `web` replicas enforced
286
+ // `login: { limit: 5 }` as fifteen attempts, with `x verify` green.
287
+ rateLimit: { scope: store?.scope ?? 'process' },
288
+ // Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
289
+ // nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
290
+ // function of that body. Read after `loadApp` — importing the app IS what registered them.
291
+ // BOTH directives, and the script half is the one that was missing: the hydration runtime
292
+ // is emitted inline in every document that carries an island, so `script-src 'self'` meant
293
+ // no island booted anywhere the policy is enforced — which is every container, and never
294
+ // `x dev`, where it is report-only.
295
+ security: {
296
+ csp: {
297
+ extend: {
298
+ 'style-src': inlineStyleSources(options.inlineStyles ?? []),
299
+ 'script-src': inlineScriptSources(),
300
+ },
287
301
  },
288
302
  },
289
- },
290
- }),
303
+ }),
304
+ ),
291
305
  }).start();
292
306
  }
293
307
 
package/src/dev-sync.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from '@ultimat3/realtime/server';
17
17
  import type { StartRolesOptions } from './dev-roles';
18
18
  import { neighbouringPort, PORT_RANGE } from './flag-number';
19
+ import { portFree } from './port-probe';
19
20
  import { syncAuthenticator } from './sync-authenticator';
20
21
 
21
22
  /**
@@ -35,6 +36,46 @@ class SyncPortUnavailableError extends UltimateError {
35
36
  }
36
37
  }
37
38
 
39
+ /**
40
+ * The neighbour was already listening. `X_PORT_IN_USE` is this package's own and is exactly what
41
+ * `x doctor` reports for the same condition, so one taken port has one name wherever it is found.
42
+ *
43
+ * What shipped instead: `listenSyncNode`'s `Bun.serve` threw, `startSync` re-threw, and the
44
+ * dispatcher rendered the caught value into `X_CLI_UNEXPECTED`'s cause —
45
+ * `cause: Error: Failed to start server. Is port 4000 in use?`, `fix: x doctor --json`, from a
46
+ * command that had just printed `web listening on 3999`. Three defects in one output: an
47
+ * unstable code, a caught value rendered into a refusal, and a `fix:` that answered
48
+ * "no findings — environment is shippable" when run (#F5).
49
+ */
50
+ class SyncPortInUseError extends UltimateError {
51
+ constructor(input: { port: number; webPort: number }) {
52
+ super({
53
+ code: 'X_PORT_IN_USE',
54
+ cause: `the sync role binds PORT + 1, so \`x dev --port ${input.webPort}\` needs port ${input.port} and something is already listening on it`,
55
+ fix: `x dev --port ${neighbouringPort(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
56
+ meta: { port: input.port, webPort: input.webPort },
57
+ });
58
+ }
59
+ }
60
+
61
+ /**
62
+ * What a failed `listenSyncNode` really was, ASKED rather than read off the caught value: the
63
+ * thrown thing is `Bun.serve`'s own English and interpolating it into a `cause:` is what
64
+ * `scripts/catch-render.ts` refuses. `undefined` means "not a taken port" and the original value
65
+ * is re-thrown untouched — a catch-all that renamed every listener failure would be worse than
66
+ * the bare one it replaced.
67
+ *
68
+ * `probe` is injected so a test can be exactly "the port was taken" without racing a real socket.
69
+ */
70
+ export async function syncBindRefusal(
71
+ webPort: number,
72
+ port: number,
73
+ probe: (value: number) => Promise<boolean> = portFree,
74
+ ): Promise<UltimateError | undefined> {
75
+ if (await probe(port)) return undefined;
76
+ return new SyncPortInUseError({ port, webPort });
77
+ }
78
+
38
79
  /**
39
80
  * The port the sync node listens on. `PORT + 1`, and `0` stays `0` — the kernel picks, and adding
40
81
  * one to it would pick a specific port instead.
@@ -131,8 +172,9 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
131
172
  }),
132
173
  });
133
174
  await node.start();
175
+ const port = syncPortFor(options.port);
134
176
  try {
135
- const listener = listenSyncNode(node, { port: syncPortFor(options.port) });
177
+ const listener = listenSyncNode(node, { port });
136
178
  return {
137
179
  url: listener.url,
138
180
  stop: async () => {
@@ -142,6 +184,8 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
142
184
  };
143
185
  } catch (error) {
144
186
  await node.stop();
187
+ const refusal = await syncBindRefusal(options.port, port);
188
+ if (refusal !== undefined) throw refusal;
145
189
  throw error;
146
190
  }
147
191
  }
@@ -52,6 +52,13 @@ export const CLI_OWNED_ERROR_CODES = [
52
52
  'X_STORAGE_UNWRITABLE',
53
53
  'X_STORAGE_SECRET_DEV',
54
54
  'X_MANIFEST_STALE',
55
+ // The other half of the same file, and the one that was silence: `x manifest` writes
56
+ // `x.manifest.json`, `AGENTS.md` tells an agent that facts live in it, `x dev` prints its path —
57
+ // and nothing ever ran the command, so the gate's `manifest` step reported green over a file
58
+ // that has never existed in any app `x new` produced. Its own code because the repair differs
59
+ // from both neighbours: `X_MANIFEST_DRIFT` means the committed file disagrees with the code and
60
+ // `X_MANIFEST_STALE` means `openapi.json` does; this one means there is nothing there at all.
61
+ 'X_MANIFEST_MISSING',
55
62
  'X_BUDGET_UNMEASURED',
56
63
  // The other half of #271, and the half no runtime can raise: a route reads a live hook and boots
57
64
  // no module in a browser, so its rows have nowhere to arrive and the page renders its loading
@@ -146,6 +153,14 @@ export const CLI_OWNED_ERROR_CODES = [
146
153
  */
147
154
  export const CLI_BORROWED_ERROR_CODES = [
148
155
  'X_NOT_IMPLEMENTED',
156
+ // `@ultimat3/policy`'s, and the gate's `policy` step reports it verbatim — cause, fix and the
157
+ // nearest declared name all come from `permissionUnknown`. A CLI-owned twin would be a second
158
+ // wording for the condition `can()` already refuses at run time, and the two would drift.
159
+ 'X_PERMISSION_UNKNOWN',
160
+ // `@ultimat3/db`'s, reported by `x doctor`'s database probe with that package's own two-branch
161
+ // fix. The CLI is the half that asks BEFORE a pool is opened; the condition and the remedy are
162
+ // both db's, and a CLI twin would be one unreachable database with two names.
163
+ 'X_DB_UNAVAILABLE',
149
164
  'X_CONFIG_INVALID',
150
165
  'X_ENV_MISSING',
151
166
  'X_ENV_EXAMPLE_DRIFT',
@@ -195,6 +210,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
195
210
  X_PACKAGE_UNREFERENCED: 'a published workspace is not in the root tsconfig build graph',
196
211
  X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
197
212
  X_MANIFEST_STALE: 'openapi.json is stale',
213
+ X_MANIFEST_MISSING: 'the app ships no x.manifest.json',
198
214
  X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
199
215
  X_LIVE_ROUTE_NO_ISLAND: 'a route reads live rows and boots nothing that could receive them',
200
216
  X_BUILD_FAILED: 'x build failed',
@@ -0,0 +1,40 @@
1
+ // The one writer of `packages/i18n/src/index.ts`, shared by `x g` and `x i18n add|sync`.
2
+ //
3
+ // A catalog file existing on disk and the app being able to SELECT that locale are two different
4
+ // facts, and only this closes the gap: the index hardcodes `locales: { en }`, so a locale whose
5
+ // catalog nothing registered renders `⟦key⟧` — which the gate's `i18n` step refuses outright
6
+ // (`X_CATALOG_UNREGISTERED`). `x i18n add fr` wrote the file, touched nothing else, and left
7
+ // `x verify` red with a fix line that named an edit nobody could perform (#F4).
8
+
9
+ // why: Bun has no synchronous existence check — `Bun.file(p).exists()` is async, and this decides
10
+ // whether to write at all, before any await the caller could interleave with.
11
+ import { existsSync } from 'node:fs';
12
+ import { containedPath } from './generate-write';
13
+ import { CATALOG_ROOT, i18nIndex } from './templates';
14
+
15
+ export const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
16
+
17
+ /** Every locale with a catalog on disk, sorted — the file names are the tags. */
18
+ export async function catalogLocales(root: string): Promise<readonly string[]> {
19
+ const catalogDir = containedPath(root, CATALOG_ROOT);
20
+ if (!existsSync(catalogDir)) return [];
21
+ const locales: string[] = [];
22
+ for await (const entry of new Bun.Glob('*.json').scan({ cwd: catalogDir, absolute: false })) {
23
+ locales.push(entry.replace(/\.json$/, ''));
24
+ }
25
+ return locales.sort();
26
+ }
27
+
28
+ /**
29
+ * Re-derives the FULL locale set from `packages/i18n/catalogs/` — never just the locale one
30
+ * invocation asked for — and rewrites the index to match. It bypasses `writeFiles` on purpose:
31
+ * this file is a projection of the catalog directory, never app-authored content a conflict check
32
+ * should protect. An app with no i18n package (deleted, or never scaffolded) is left alone, and
33
+ * that is what `written` reports.
34
+ */
35
+ export async function syncI18nIndex(root: string): Promise<boolean> {
36
+ const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
37
+ if (!existsSync(indexAbsolute)) return false;
38
+ await Bun.write(indexAbsolute, i18nIndex(await catalogLocales(root)));
39
+ return true;
40
+ }