@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.
- package/CLAUDE.md +50 -1
- package/README.md +4 -4
- package/package.json +28 -28
- package/src/app-permissions.ts +0 -0
- package/src/cmd-dev.ts +6 -0
- package/src/cmd-doctor.ts +74 -16
- package/src/cmd-errors.ts +6 -0
- package/src/cmd-generate.ts +2 -27
- package/src/cmd-i18n.ts +16 -1
- package/src/dev-queue.ts +49 -9
- package/src/dev-replica.ts +99 -0
- package/src/dev-roles-fixture.ts +7 -0
- package/src/dev-roles.ts +44 -30
- package/src/dev-sync.ts +45 -1
- package/src/error-codes.ts +16 -0
- package/src/i18n-index.ts +40 -0
- package/src/i18n-registration.ts +43 -1
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +6 -1
- package/src/parse.ts +39 -6
- package/src/port-probe.ts +22 -0
- package/src/serve.ts +7 -1
- package/src/templates/scaffold-app.ts +2 -0
- package/src/templates/scaffold-docs.ts +10 -4
- package/src/templates/scaffold-http.ts +84 -0
- package/src/templates/scaffold-repo.ts +20 -0
- package/src/templates/scaffold-roles.ts +31 -3
- package/src/verify-checks.ts +36 -0
- package/src/verify-step.ts +8 -0
|
@@ -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
|
+
}
|
package/src/dev-roles-fixture.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
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
|
}
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/i18n-registration.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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() },
|