@ultimat3/cli 11.3.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.
@@ -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
+ }
@@ -4,6 +4,9 @@
4
4
  // source against files on disk and was green for every string of a shipped app whose catalog
5
5
  // module nothing imported (issue #249).
6
6
 
7
+ // why: Bun ships no path API, so `join` is the only way to reach the app's own i18n module on
8
+ // the host's separator.
9
+ import { join } from 'node:path';
7
10
  import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
8
11
  import {
9
12
  auditCatalogs,
@@ -17,9 +20,10 @@ import {
17
20
  } from '@ultimat3/i18n';
18
21
  import { loadApp } from './app-load';
19
22
  import { auditApp } from './i18n-audit';
23
+ import { I18N_INDEX_PATH } from './i18n-index';
20
24
  import type { Finding } from './output';
21
25
  import { findingFrom } from './output';
22
- import { catalogPath } from './templates/locales';
26
+ import { CATALOG_ROOT, catalogPath } from './templates/locales';
23
27
 
24
28
  /**
25
29
  * What this check needs of a boot. The seam is injected so a fixture can be exactly "the app
@@ -75,8 +79,10 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
75
79
  const app = await (input.load ?? loadApp)(input.root);
76
80
 
77
81
  const gaps = catalogRegistrationGaps(input.catalogs);
82
+ const index = await indexSource(input.root);
78
83
  const findings: Finding[] = gaps.map((gap) => ({
79
84
  ...findingFrom(catalogUnregistered(gap)),
85
+ ...unregisteredFix(gap.locale, index),
80
86
  at: catalogPath(gap.locale),
81
87
  }));
82
88
  let unregistered = gaps.reduce((sum, gap) => sum + gap.missing.length, 0);
@@ -105,6 +111,42 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
105
111
  };
106
112
  }
107
113
 
114
+ /**
115
+ * `packages/i18n/src/index.ts` as text, or `undefined` where the app has no i18n package. Read
116
+ * here and nowhere lower down: `@ultimat3/i18n` states that it never reads a file, and the CLI is
117
+ * the half that knows what an app's directories are.
118
+ */
119
+ async function indexSource(root: string): Promise<string | undefined> {
120
+ const file = Bun.file(join(root, I18N_INDEX_PATH));
121
+ return (await file.exists()) ? file.text() : undefined;
122
+ }
123
+
124
+ /**
125
+ * `X_CATALOG_UNREGISTERED` is one code over two causes, and until now it printed one fix for both.
126
+ * `@ultimat3/i18n`'s is written for the app whose `defineCatalogs()` call is in a module nothing
127
+ * imports — "move it into packages/i18n/src/index.ts (where `x new` puts it)". For a locale added
128
+ * by `x i18n add` the call is ALREADY there and the locale is simply not in its `locales:` map, so
129
+ * that instruction names an edit with nothing to perform: an agent following it verbatim changes
130
+ * nothing, re-runs, and is red again, on the command whose whole job is adding a locale (#F4).
131
+ *
132
+ * The condition is narrow enough that the finding never has to be argued with — the index exists,
133
+ * and the locale's tag appears nowhere in it — and the replacement is a command that performs the
134
+ * registration rather than describing it.
135
+ */
136
+ export function unregisteredFix(
137
+ locale: string,
138
+ index: string | undefined,
139
+ ): { readonly fix?: string } {
140
+ if (index === undefined) return {};
141
+ // The index is GENERATED (`i18nIndex`), so the one spelling that matters is the import it writes
142
+ // — `catalogs/<tag>.json`. Matching a bare tag instead would read `en` out of the word `key` and
143
+ // report a registered locale as unregistered, which is the direction that costs trust.
144
+ if (index.includes(`${CATALOG_ROOT.split('/').pop() ?? 'catalogs'}/${locale}.json`)) return {};
145
+ return {
146
+ fix: `x i18n sync ${locale} # re-derives ${I18N_INDEX_PATH} from the catalogs on disk`,
147
+ };
148
+ }
149
+
108
150
  /**
109
151
  * `⟦key⟧` — `@ultimat3/i18n`'s own loud miss, spelled ONCE for the whole CLI. `x i18n sync <default>`
110
152
  * writes it and the two checks below refuse it, so a second spelling would be a placeholder one
package/src/mcp-errors.ts CHANGED
@@ -99,6 +99,18 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
99
99
  X_STORAGE_UNWRITABLE: 'x doctor --json',
100
100
  X_STORAGE_SECRET_DEV: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
101
101
  X_MANIFEST_STALE: 'x manifest --json',
102
+ // The file itself, absent. One command writes it, and `bin/setup` now runs that command — so
103
+ // the fix here is the same one the gate's finding carries rather than a second phrasing.
104
+ X_MANIFEST_MISSING: 'x manifest --json',
105
+ // `@ultimat3/policy`'s code, and the CLI is the surface an agent reaches it from: `x policy list`
106
+ // is the only thing that prints the set the permission is missing from. The declare-it half is
107
+ // an edit to the app's own `definePermissions([...])`, which no command can perform.
108
+ X_PERMISSION_UNKNOWN:
109
+ 'x policy list --json # then add the permission to the app definePermissions([...]) call, or fix the typo',
110
+ // `@ultimat3/db`'s code, reported by `x doctor`'s probe. Both branches of db's own fix are an
111
+ // environment edit, so the runnable half is the probe that says which one is needed.
112
+ X_DB_UNAVAILABLE:
113
+ 'x doctor --json # set DATABASE_URL to a reachable Postgres url, or unset it for embedded PGlite',
102
114
  // `--target static`, not a bare `x build`: `--target` defaults to `docker`, and only the static
103
115
  // target runs `apps/web/prerender.ts` — the one caller of `writeBuildStats`. Without the flag
104
116
  // this fix builds an image, writes no `.x/build-stats.json`, and the next `x verify` reports the
package/src/messages.ts CHANGED
@@ -140,7 +140,12 @@ const CATALOG = {
140
140
  // `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no migration, so the
141
141
  // drift step is red until it has), migrates and seeds. The four-command line this replaced named
142
142
  // `x dev` off a tree where nothing had installed the CLI yet, and skipped the seed entirely.
143
- 'cli.new.done': 'created {name} — next: cd {name} && bin/setup && x dev',
143
+ // `bin/dev`, never `x dev`: `bun install` links the binary into `./node_modules/.bin` and
144
+ // nowhere else, so the bare `x` this line printed is not on PATH in the shell it is pasted into
145
+ // (proved with `env -i PATH=… command -v x`). The scaffold's own `bin/` wrappers are the form
146
+ // that works from a fresh clone, and `bin/setup` already uses `bunx x` internally for this
147
+ // reason.
148
+ 'cli.new.done': 'created {name} — next: cd {name} && bin/setup && bin/dev',
144
149
  // The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
145
150
  // one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
146
151
  // command is a broken one — the same split `Finding.fix` already makes.
package/src/parse.ts CHANGED
@@ -47,6 +47,19 @@ export interface CommandSpec {
47
47
  * sorted first. A command with no defensible default omits this and the parser refuses instead.
48
48
  */
49
49
  readonly defaultSubcommand?: string;
50
+ /**
51
+ * Whether a first word that is NOT a subcommand is an ARGUMENT to `defaultSubcommand` rather
52
+ * than a misspelt one. Declared per command, never inferred, because only some commands can say
53
+ * it truthfully: `x errors X_PERMISSION_UNKNOWN` can only be a code, and `x jobs 4f2a` is
54
+ * genuinely ambiguous with `show`, so an unconditional fallback would turn `x jobs <id>` into a
55
+ * silent `x jobs ls` that ignores the id.
56
+ *
57
+ * `x errors X_PERMISSION_UNKNOWN --json` answered `X_CLI_UNKNOWN_COMMAND … fix: x help`, and
58
+ * `x help` prints `errors an X_* code, explained` — which reads as exactly the form that was
59
+ * refused (#F16). A near miss is still refused with its suggestion, so `x errors explan X_FOO`
60
+ * does not quietly become a lookup of the code `explan`.
61
+ */
62
+ readonly defaultSubcommandTakesPositional?: boolean;
50
63
  /**
51
64
  * A closed set the FIRST positional must come from, where the command has one. Declarative only:
52
65
  * the parser leaves positionals to the command, because `x test`'s own `readOnlyType` already
@@ -232,12 +245,16 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
232
245
  // asking what the usage is, on every command that takes a subcommand. Help is answered by
233
246
  // `dispatch`, which needs only the command name.
234
247
  const help = flags.get('help') === true;
235
- const subcommand = help ? undefined : readSubcommand(spec, positionals);
248
+ const resolved = help ? NO_SUBCOMMAND : readSubcommand(spec, positionals);
249
+ const subcommand = resolved.name;
236
250
  if (subcommand !== undefined) assertFlagsApply(spec, subcommand, given, flags);
237
251
  return {
238
252
  command: spec.name,
239
253
  subcommand,
240
- positionals: subcommand === undefined ? positionals : positionals.slice(1),
254
+ // `consumed`, never `subcommand !== undefined`: a default subcommand the caller did not TYPE
255
+ // leaves its first positional in place, which is what makes `x errors X_DB_DRIFT` the same
256
+ // invocation as `x errors explain X_DB_DRIFT` instead of one with its argument eaten.
257
+ positionals: resolved.consumed ? positionals.slice(1) : positionals,
241
258
  flags,
242
259
  json: flags.get('json') === true,
243
260
  help,
@@ -278,16 +295,32 @@ function splitInline(raw: string): [string, string | undefined] {
278
295
  return [raw.slice(0, eq), raw.slice(eq + 1)];
279
296
  }
280
297
 
281
- function readSubcommand(spec: CommandSpec, positionals: readonly string[]): string | undefined {
298
+ /** Which subcommand ran, and whether the caller's first positional is what named it. */
299
+ interface ResolvedSubcommand {
300
+ readonly name: string | undefined;
301
+ readonly consumed: boolean;
302
+ }
303
+
304
+ const NO_SUBCOMMAND: ResolvedSubcommand = { name: undefined, consumed: false };
305
+
306
+ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): ResolvedSubcommand {
282
307
  const allowed = spec.subcommands;
283
- if (allowed === undefined || allowed.length === 0) return undefined;
308
+ if (allowed === undefined || allowed.length === 0) return NO_SUBCOMMAND;
284
309
  const token = positionals[0];
285
310
  if (token === undefined) {
286
- if (spec.defaultSubcommand !== undefined) return spec.defaultSubcommand;
311
+ if (spec.defaultSubcommand !== undefined) {
312
+ return { name: spec.defaultSubcommand, consumed: false };
313
+ }
287
314
  throw new MissingSubcommandError({ command: spec.name, known: allowed });
288
315
  }
289
- if (allowed.includes(token)) return token;
316
+ if (allowed.includes(token)) return { name: token, consumed: true };
290
317
  const suggestion = nearestName(token, allowed);
318
+ // The declared fallback, and only past the near-miss guard: a word within `nearestName`'s edit
319
+ // budget of a real subcommand is a typo, and reading it as the default subcommand's argument
320
+ // would answer a question nobody asked.
321
+ if (spec.defaultSubcommandTakesPositional === true && suggestion === undefined) {
322
+ return { name: spec.defaultSubcommand, consumed: false };
323
+ }
291
324
  throw new UnknownCommandError(
292
325
  suggestion === undefined
293
326
  ? { path: `${spec.name} ${token}`, known: allowed }
@@ -0,0 +1,22 @@
1
+ // Can this process bind that port? One implementation, because two commands ask it and they must
2
+ // not disagree: `x doctor` reports it as a finding, and `startSync` asks it after a bind failure to
3
+ // name the real cause instead of rendering a caught `Error` into a refusal.
4
+
5
+ /**
6
+ * Binds and immediately releases. `Bun.serve({ port: 0 })` ALWAYS succeeds — the kernel picks —
7
+ * so 0 is answered `true` without opening anything: a probe that cannot fail is worse than none,
8
+ * and `x dev --port 0` genuinely has no port to be in use.
9
+ */
10
+ export async function portFree(port: number): Promise<boolean> {
11
+ if (port === 0) return true;
12
+ try {
13
+ const server = Bun.serve({ port, fetch: () => new Response('') });
14
+ await server.stop(true);
15
+ return true;
16
+ } catch {
17
+ // Deliberately swallowed and never rendered: the caught value is `Bun.serve`'s own
18
+ // `Failed to start server. Is port N in use?`, and interpolating it into a `cause:` is exactly
19
+ // what `scripts/catch-render.ts` refuses. The ANSWER is the boolean; the caller owns the words.
20
+ return false;
21
+ }
22
+ }
package/src/serve.ts CHANGED
@@ -28,6 +28,7 @@ import { appManifest } from './app-manifest';
28
28
  import { assetRoutes } from './dev-assets';
29
29
  import { startQueue } from './dev-queue';
30
30
  import { appRoutes } from './dev-render';
31
+ import { replicaOverrides } from './dev-replica';
31
32
  import type { RunningRoles, WebBinding } from './dev-roles';
32
33
  import { startRoles } from './dev-roles';
33
34
  import type { RunningServices } from './dev-runtime';
@@ -317,6 +318,7 @@ async function bootRoles(boot: {
317
318
  // fixed 9090 would fail the next suite to boot beside it. An environment that names the port
318
319
  // still wins — that is the deploy talking.
319
320
  const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
321
+ const replicaOverride = replicaOverrides(options.runtime, runtime.services.db, options.env);
320
322
  const running = await startRoles({
321
323
  roles: [role],
322
324
  port,
@@ -332,7 +334,11 @@ async function bootRoles(boot: {
332
334
  // process and `x dev` cannot answer a browser differently.
333
335
  root: options.root,
334
336
  http: CONTAINER_BINDING,
335
- ...(options.runtime === undefined ? {} : { overrides: options.runtime }),
337
+ // The read-replica scope rides in FRONT of whatever the host supplied, or the host's own value
338
+ // passes through untouched. `DATABASE_REPLICA_URL` was read by no booted process before this:
339
+ // `defaultClient()` is the one composer of a replicated pair and it runs only when an app
340
+ // installed no client, which no framework boot leaves true (`dev-queue.ts`).
341
+ ...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
336
342
  });
337
343
  acquired.push(() => running.stop());
338
344
  return {
@@ -7,6 +7,7 @@ import type { GeneratedFile, NameSet } from './naming';
7
7
  import { apiFiles } from './scaffold-api';
8
8
  import { authFiles } from './scaffold-auth';
9
9
  import { entryFiles } from './scaffold-entries';
10
+ import { httpFiles } from './scaffold-http';
10
11
  import { icon } from './scaffold-icon';
11
12
  import { rolesFiles } from './scaffold-roles';
12
13
 
@@ -401,6 +402,7 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
401
402
  // The app's role map, beside the actor that reads it. `shared/` and not a feature folder:
402
403
  // `defineRoles()` merges, so a per-feature call is legal and is how an app ends up with no
403
404
  // answer to "which roles exist?" — see `scaffold-roles.ts`.
405
+ ...httpFiles(app),
404
406
  ...rolesFiles(),
405
407
  { path: 'apps/admin/package.json', contents: adminPackage(app) },
406
408
  { path: 'apps/admin/tsconfig.json', contents: tsconfig() },