@ultimat3/cli 7.0.0 → 8.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 +15 -1
- package/README.md +8 -3
- package/package.json +25 -25
- package/src/app-boundaries.ts +55 -5
- package/src/bin.ts +6 -3
- package/src/ci-log.ts +0 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +37 -3
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +55 -4
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +68 -6
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-verify.ts +47 -6
- package/src/dev-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +5 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +1 -1
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +12 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/island.ts +2 -1
- package/src/templates/route.ts +1 -1
- package/src/templates/scaffold-app.ts +3 -82
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +14 -6
- package/src/templates/scaffold-docs.ts +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +37 -6
- package/src/test-select.ts +4 -3
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/write-line.ts +23 -5
package/src/parse.ts
CHANGED
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
// same way and `--json` / `--help` behave identically everywhere. Pure: no I/O, no process
|
|
3
3
|
// access, so the parser is unit-testable and the dispatcher owns all side effects.
|
|
4
4
|
|
|
5
|
+
import { nearestName } from '@ultimat3/core';
|
|
5
6
|
import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './errors';
|
|
7
|
+
// `shell-quote.ts` is a leaf — it imports nothing — so the parser stays pure and importable from
|
|
8
|
+
// anywhere while still refusing with a fix line a shell reads as one argument.
|
|
9
|
+
import { quoteArg } from './shell-quote';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The historical name for `@ultimat3/core`'s `nearestName`, kept because it shipped on this
|
|
13
|
+
* package's exported surface and removing it would be a major for a rename. One implementation
|
|
14
|
+
* behind both — this is a delegation, not the second copy of the algorithm that `@ultimat3/policy`
|
|
15
|
+
* used to carry. New callers import `nearestName` from core.
|
|
16
|
+
*/
|
|
17
|
+
export const nearest = nearestName;
|
|
6
18
|
|
|
7
19
|
export type FlagValue = string | boolean;
|
|
8
20
|
|
|
@@ -12,6 +24,15 @@ export interface FlagSpec {
|
|
|
12
24
|
readonly summary: string;
|
|
13
25
|
readonly short?: string;
|
|
14
26
|
readonly default?: FlagValue;
|
|
27
|
+
/**
|
|
28
|
+
* The subcommands that READ this flag, where it is not command-wide. Absent means every one —
|
|
29
|
+
* opt-in, because most flags really are. Declared from the same fact the summary states, and
|
|
30
|
+
* enforced: `x db gen --dry-run` parsed, ran the generator and WROTE the migration, because the
|
|
31
|
+
* parser validates a flag against the COMMAND and nothing then validates it against the word
|
|
32
|
+
* that decides what runs. A dry run that writes a file is the direction a mistake may never
|
|
33
|
+
* fail in. `parse.test.ts` pins that every entry names a subcommand its command declares.
|
|
34
|
+
*/
|
|
35
|
+
readonly subcommands?: readonly string[];
|
|
15
36
|
}
|
|
16
37
|
|
|
17
38
|
export interface CommandSpec {
|
|
@@ -87,41 +108,11 @@ export const wantsJson = (argv: readonly string[]): boolean =>
|
|
|
87
108
|
const HELP_ALIASES = new Set(['--help', '-h', 'help']);
|
|
88
109
|
const VERSION_ALIASES = new Set(['--version', '-v', '-V']);
|
|
89
110
|
|
|
90
|
-
function distance(a: string, b: string): number {
|
|
91
|
-
const rows = a.length + 1;
|
|
92
|
-
const cols = b.length + 1;
|
|
93
|
-
const grid: number[] = new Array<number>(rows * cols).fill(0);
|
|
94
|
-
const at = (r: number, c: number): number => grid[r * cols + c] ?? 0;
|
|
95
|
-
for (let r = 0; r < rows; r += 1) grid[r * cols] = r;
|
|
96
|
-
for (let c = 0; c < cols; c += 1) grid[c] = c;
|
|
97
|
-
for (let r = 1; r < rows; r += 1) {
|
|
98
|
-
for (let c = 1; c < cols; c += 1) {
|
|
99
|
-
const cost = a[r - 1] === b[c - 1] ? 0 : 1;
|
|
100
|
-
grid[r * cols + c] = Math.min(at(r - 1, c) + 1, at(r, c - 1) + 1, at(r - 1, c - 1) + cost);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return at(rows - 1, cols - 1);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Nearest known name within an edit distance of 3, so the error can suggest a retry. */
|
|
107
|
-
export function nearest(input: string, candidates: readonly string[]): string | undefined {
|
|
108
|
-
let best: string | undefined;
|
|
109
|
-
let bestScore = 4;
|
|
110
|
-
for (const candidate of candidates) {
|
|
111
|
-
const score = distance(input, candidate);
|
|
112
|
-
if (score < bestScore) {
|
|
113
|
-
best = candidate;
|
|
114
|
-
bestScore = score;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return best;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
111
|
function resolveCommand(token: string, specs: readonly CommandSpec[]): CommandSpec {
|
|
121
112
|
const found = specs.find((spec) => spec.name === token || (spec.aliases ?? []).includes(token));
|
|
122
113
|
if (found !== undefined) return found;
|
|
123
114
|
const names = specs.map((spec) => spec.name);
|
|
124
|
-
const suggestion =
|
|
115
|
+
const suggestion = nearestName(token, names);
|
|
125
116
|
throw new UnknownCommandError(
|
|
126
117
|
suggestion === undefined
|
|
127
118
|
? { path: token, known: names }
|
|
@@ -168,6 +159,9 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
168
159
|
const spec = resolveCommand(first, specs);
|
|
169
160
|
const flags = defaults(spec);
|
|
170
161
|
const positionals: string[] = [];
|
|
162
|
+
// What argv actually SET, as against what `defaults()` seeded: a default is nobody's request,
|
|
163
|
+
// and refusing a flag the caller never typed would refuse the command itself.
|
|
164
|
+
const given = new Set<string>();
|
|
171
165
|
let index = 1;
|
|
172
166
|
|
|
173
167
|
while (index < tokens.length) {
|
|
@@ -183,7 +177,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
183
177
|
const flag = findFlag(name, spec);
|
|
184
178
|
if (flag === undefined) {
|
|
185
179
|
const known = [...GLOBAL_FLAGS, ...(spec.flags ?? [])].map((entry) => entry.name);
|
|
186
|
-
const suggestion =
|
|
180
|
+
const suggestion = nearestName(name, known);
|
|
187
181
|
throw new BadFlagError({
|
|
188
182
|
flag: name,
|
|
189
183
|
command: spec.name,
|
|
@@ -193,6 +187,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
193
187
|
: `unknown flag — did you mean --${suggestion}?`,
|
|
194
188
|
});
|
|
195
189
|
}
|
|
190
|
+
given.add(flag.name);
|
|
196
191
|
if (flag.type === 'boolean') {
|
|
197
192
|
if (inlineValue !== undefined) {
|
|
198
193
|
throw new BadFlagError({
|
|
@@ -204,30 +199,79 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
204
199
|
flags.set(flag.name, !negated);
|
|
205
200
|
continue;
|
|
206
201
|
}
|
|
202
|
+
// `--no-<string flag>` used to fall through to the value read below, so `--no-name feat` set
|
|
203
|
+
// `name` to `feat`: the caller asked for the flag to be OFF and argv's next token became its
|
|
204
|
+
// value. There is nothing a string flag can be negated to, so this is a refusal.
|
|
205
|
+
if (negated) {
|
|
206
|
+
throw new BadFlagError({
|
|
207
|
+
flag: flag.name,
|
|
208
|
+
command: spec.name,
|
|
209
|
+
reason: `--no- negates a boolean flag, and --${flag.name} takes a value`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
207
212
|
const value = inlineValue ?? tokens[index];
|
|
208
|
-
if (value === undefined
|
|
213
|
+
if (value === undefined) {
|
|
214
|
+
throw new BadFlagError({ flag: flag.name, command: spec.name, reason: 'expects a value' });
|
|
215
|
+
}
|
|
216
|
+
// A value beginning `--` is a flag as far as this loop can tell, and the caller who really
|
|
217
|
+
// meant it as a value has one form available — so the refusal names it rather than leaving
|
|
218
|
+
// `--filter --json` looking like a parser that cannot express the input.
|
|
219
|
+
if (value.startsWith('--')) {
|
|
209
220
|
throw new BadFlagError({
|
|
210
221
|
flag: flag.name,
|
|
211
222
|
command: spec.name,
|
|
212
|
-
reason:
|
|
223
|
+
reason: `expects a value, and "${value}" is a flag — write --${flag.name}=${value} to pass it as the value`,
|
|
213
224
|
});
|
|
214
225
|
}
|
|
215
226
|
if (inlineValue === undefined) index += 1;
|
|
216
227
|
flags.set(flag.name, value);
|
|
217
228
|
}
|
|
218
229
|
|
|
219
|
-
|
|
230
|
+
// Before `readSubcommand`, which THROWS on a missing or unknown one: `x db --help`, `x mcp
|
|
231
|
+
// --help` and `x pr --help` all exited 1 with `X_CLI_BAD_FLAG` — usage refused to the caller
|
|
232
|
+
// asking what the usage is, on every command that takes a subcommand. Help is answered by
|
|
233
|
+
// `dispatch`, which needs only the command name.
|
|
234
|
+
const help = flags.get('help') === true;
|
|
235
|
+
const subcommand = help ? undefined : readSubcommand(spec, positionals);
|
|
236
|
+
if (subcommand !== undefined) assertFlagsApply(spec, subcommand, given, flags);
|
|
220
237
|
return {
|
|
221
238
|
command: spec.name,
|
|
222
239
|
subcommand,
|
|
223
240
|
positionals: subcommand === undefined ? positionals : positionals.slice(1),
|
|
224
241
|
flags,
|
|
225
242
|
json: flags.get('json') === true,
|
|
226
|
-
help
|
|
243
|
+
help,
|
|
227
244
|
passthrough,
|
|
228
245
|
};
|
|
229
246
|
}
|
|
230
247
|
|
|
248
|
+
/**
|
|
249
|
+
* A flag the resolved subcommand does not read is refused, never ignored. Only what argv SET is
|
|
250
|
+
* judged, and only against a flag that declared a scope: an undeclared flag stays command-wide.
|
|
251
|
+
*
|
|
252
|
+
* The fix carries the caller's own value through `quoteArg`, because it is pasted into a shell
|
|
253
|
+
* verbatim — `x db backfill --status 'a b'` runs, `--status a b` runs something else.
|
|
254
|
+
*/
|
|
255
|
+
function assertFlagsApply(
|
|
256
|
+
spec: CommandSpec,
|
|
257
|
+
subcommand: string,
|
|
258
|
+
given: ReadonlySet<string>,
|
|
259
|
+
flags: ReadonlyMap<string, FlagValue>,
|
|
260
|
+
): void {
|
|
261
|
+
for (const flag of spec.flags ?? []) {
|
|
262
|
+
const only = flag.subcommands;
|
|
263
|
+
if (only === undefined || only.includes(subcommand) || !given.has(flag.name)) continue;
|
|
264
|
+
const value = flags.get(flag.name);
|
|
265
|
+
const argument = typeof value === 'string' ? ` ${quoteArg(value)}` : '';
|
|
266
|
+
throw new BadFlagError({
|
|
267
|
+
flag: flag.name,
|
|
268
|
+
command: `${spec.name} ${subcommand}`,
|
|
269
|
+
reason: `read by ${only.map((word) => `x ${spec.name} ${word}`).join(' / ')} only — "${subcommand}" would ignore it`,
|
|
270
|
+
fix: `x ${spec.name} ${only[0]} --${flag.name}${argument}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
231
275
|
function splitInline(raw: string): [string, string | undefined] {
|
|
232
276
|
const eq = raw.indexOf('=');
|
|
233
277
|
if (eq === -1) return [raw, undefined];
|
|
@@ -243,7 +287,7 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): stri
|
|
|
243
287
|
throw new MissingSubcommandError({ command: spec.name, known: allowed });
|
|
244
288
|
}
|
|
245
289
|
if (allowed.includes(token)) return token;
|
|
246
|
-
const suggestion =
|
|
290
|
+
const suggestion = nearestName(token, allowed);
|
|
247
291
|
throw new UnknownCommandError(
|
|
248
292
|
suggestion === undefined
|
|
249
293
|
? { path: `${spec.name} ${token}`, known: allowed }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The browser island `wiki/Realtime.md` promises: one live hook and nothing else. It is a real
|
|
2
|
+
// module rather than a string a test writes to a temp path, because module resolution is the thing
|
|
3
|
+
// under test — `@ultimat3/realtime`'s client entry must reach neither the bus nor the WAL decoder.
|
|
4
|
+
// Bundled AND imported by `realtime-browser-barrel.test.ts` — the import is what gives it an lcov
|
|
5
|
+
// record, since `Bun.build()` reads this file without evaluating it.
|
|
6
|
+
|
|
7
|
+
import { useLive } from '@ultimat3/realtime';
|
|
8
|
+
|
|
9
|
+
export const probeUseLive = useLive;
|
package/src/runtime-overrides.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { PurgeDriver } from '@ultimat3/cache';
|
|
|
8
8
|
import type { Middleware, RateLimitStore } from '@ultimat3/http';
|
|
9
9
|
import type { JobDriver } from '@ultimat3/jobs';
|
|
10
10
|
import type { MailDriver } from '@ultimat3/mail';
|
|
11
|
-
import type { SyncAuthenticator, Transport } from '@ultimat3/realtime';
|
|
11
|
+
import type { SyncAuthenticator, Transport } from '@ultimat3/realtime/server';
|
|
12
12
|
import type { IsrStore } from '@ultimat3/render';
|
|
13
13
|
import type { ImageTransformDriver } from '@ultimat3/seo';
|
|
14
14
|
import type { Storage } from '@ultimat3/storage';
|
|
@@ -45,6 +45,11 @@ export interface RuntimeOverrides {
|
|
|
45
45
|
* Where the HTTP rate limiter keeps its counters. It also DECIDES `rateLimit.scope`: a store
|
|
46
46
|
* that says `'shared'` is a deployment declaring fleet-wide numbers, and `assertRateLimitScope`
|
|
47
47
|
* holds the two halves together rather than a literal in the boot contradicting the store.
|
|
48
|
+
*
|
|
49
|
+
* Omitted, the boot installs `postgresRateLimitStore` over the pool it already opened
|
|
50
|
+
* (`startServices`) — so this replaces a SHARED default, not an absent one. A store whose scope
|
|
51
|
+
* is `'process'` is legal and warned about: it is every declared limit enforced once per
|
|
52
|
+
* replica, and `docker/helm/values.yaml` runs three.
|
|
48
53
|
*/
|
|
49
54
|
readonly rateLimitStore?: RateLimitStore;
|
|
50
55
|
/**
|
|
@@ -59,8 +64,11 @@ export interface RuntimeOverrides {
|
|
|
59
64
|
readonly images?: ImageTransformDriver;
|
|
60
65
|
/**
|
|
61
66
|
* Who is dialling the `sync` node. Omitted, the app's own `configureAuthenticator()` is adapted
|
|
62
|
-
* —
|
|
63
|
-
*
|
|
67
|
+
* — and that adapter now carries `expiresAt` and `refresh` of its own (`SYNC_GRANT_TTL_MS`),
|
|
68
|
+
* re-asking the app's resolver with the upgrade's own `cookie`/`authorization`. So this field is
|
|
69
|
+
* no longer the only way to get re-authorization; it is how a deployment states a window the
|
|
70
|
+
* credential itself declares (a token's `exp`), or resolves identity from a header the adapter
|
|
71
|
+
* deliberately does not retain per socket.
|
|
64
72
|
*/
|
|
65
73
|
readonly syncAuthenticate?: SyncAuthenticator;
|
|
66
74
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// When a shot may be TAKEN: the two rules that decide whether the page has finished hydrating,
|
|
2
|
+
// separated from both the command that drives a browser and the verdict that judges what came
|
|
3
|
+
// back. Plain values and an injected sleep, so the whole loop is proved with neither.
|
|
4
|
+
|
|
5
|
+
import type { IslandCount } from './shot-verdict';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* When there is nothing left to wait for. `booted` counts the islands whose chunk the runtime
|
|
9
|
+
* ASKED for; `mounted` and `failed` are the two ways that request can end — so the outcome of
|
|
10
|
+
* every boot exists exactly when they add up to it. An island that never booted is not something
|
|
11
|
+
* to wait for (`visible` with nothing scrolled to it, `never` by declaration), and `null` is "the
|
|
12
|
+
* page answered no probe", which no amount of waiting turns into an answer.
|
|
13
|
+
*/
|
|
14
|
+
export const islandsSettled = (islands: IslandCount | null): boolean =>
|
|
15
|
+
islands === null || islands.mounted + islands.failed >= islands.booted;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How often the island probe is re-read while the page settles. Short enough that a page whose
|
|
19
|
+
* mounts have already resolved pays one extra read and nothing else.
|
|
20
|
+
*/
|
|
21
|
+
export const SETTLE_POLL_MS = 100;
|
|
22
|
+
|
|
23
|
+
export interface SettleOptions {
|
|
24
|
+
/** The extra budget a mount gets AFTER the boot deadline. Bounded: a picture is still owed. */
|
|
25
|
+
readonly windowMs: number;
|
|
26
|
+
readonly pollMs: number;
|
|
27
|
+
/** Injected by the test, so the poll is proved without spending its own window in real time. */
|
|
28
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read the probe until every booted island has settled, or the window runs out.
|
|
33
|
+
*
|
|
34
|
+
* `DEFAULT_SETTLE_MS` is the deadline at which the hydration runtime CALLS `import()` — `mounted`
|
|
35
|
+
* and `failed` land after it — so a single read at that instant reports `mounted: 0` for a page
|
|
36
|
+
* that hydrates perfectly and the verdict was taken one tick before the outcome existed. Polling
|
|
37
|
+
* is the only shape that ends EARLY on a fast page and still bounds a slow one.
|
|
38
|
+
*
|
|
39
|
+
* A `null` answer never overwrites a real count: `null` means "not counted", and a probe that
|
|
40
|
+
* fails once would otherwise turn a page with islands into a page reported to have none.
|
|
41
|
+
*/
|
|
42
|
+
export async function settleIslands(
|
|
43
|
+
probe: () => Promise<IslandCount | null>,
|
|
44
|
+
options: SettleOptions,
|
|
45
|
+
): Promise<IslandCount | null> {
|
|
46
|
+
const sleep = options.sleep ?? ((ms: number): Promise<void> => Bun.sleep(ms));
|
|
47
|
+
let answer = await probe();
|
|
48
|
+
let waited = 0;
|
|
49
|
+
while (!islandsSettled(answer) && waited < options.windowMs) {
|
|
50
|
+
// At least 1ms, or a `pollMs` of zero is a loop with no exit while the window stands.
|
|
51
|
+
const step = Math.max(1, Math.min(options.pollMs, options.windowMs - waited));
|
|
52
|
+
await sleep(step);
|
|
53
|
+
waited += step;
|
|
54
|
+
answer = (await probe()) ?? answer;
|
|
55
|
+
}
|
|
56
|
+
return answer;
|
|
57
|
+
}
|
package/src/shot-verdict.ts
CHANGED
|
@@ -33,6 +33,7 @@ export const SHOT_MESSAGE_KEYS = [
|
|
|
33
33
|
'cli.shot.canvasUnreadable',
|
|
34
34
|
'cli.shot.islands',
|
|
35
35
|
'cli.shot.islandsUnknown',
|
|
36
|
+
'cli.shot.islandFailed',
|
|
36
37
|
'cli.shot.network',
|
|
37
38
|
'cli.shot.console',
|
|
38
39
|
'cli.shot.threw',
|
|
@@ -176,19 +177,28 @@ const levelCount = (lines: readonly ConsoleLine[], level: ConsoleLine['level']):
|
|
|
176
177
|
lines.filter((line) => line.level === level).length;
|
|
177
178
|
|
|
178
179
|
/**
|
|
179
|
-
* `ok` is
|
|
180
|
-
* logged an error, nothing THREW, and the document photographed is
|
|
180
|
+
* `ok` is four conditions, and every one is something a picture cannot show: nothing on the page
|
|
181
|
+
* logged an error, nothing THREW, no island's `mount()` REJECTED, and the document photographed is
|
|
182
|
+
* the route that was asked for.
|
|
181
183
|
*
|
|
182
184
|
* The throw is its own clause rather than folded into `errors` because an uncaught exception calls
|
|
183
185
|
* no console method — a page whose island died can log nothing at all, and `errors === 0` would
|
|
184
|
-
* then pass it. A
|
|
186
|
+
* then pass it. A rejected mount is a third silent one and was read by NOTHING until 2026-08-22:
|
|
187
|
+
* the prelude pays 129 B an island to write `data-x-failed`, the probe counted it into the
|
|
188
|
+
* artifact, and every island on a page could reject while the run reported "clean". `?? 0` keeps
|
|
189
|
+
* an uncounted probe (`null`) out of the verdict — "not counted" is not "none failed".
|
|
190
|
+
* A redirect is a failure of the CAPTURE rather than of the app: an agent that
|
|
185
191
|
* photographs the sign-in page and files "the island did not mount" is the outcome this prevents.
|
|
186
192
|
*/
|
|
187
193
|
export function buildVerdict(input: ShotInput): ShotVerdict {
|
|
188
194
|
const errors = levelCount(input.console, 'error');
|
|
189
195
|
return {
|
|
190
196
|
...input,
|
|
191
|
-
ok:
|
|
197
|
+
ok:
|
|
198
|
+
errors === 0 &&
|
|
199
|
+
input.pageErrors.length === 0 &&
|
|
200
|
+
(input.islands?.failed ?? 0) === 0 &&
|
|
201
|
+
input.requestedUrl === input.finalUrl,
|
|
192
202
|
redirected: input.requestedUrl !== input.finalUrl,
|
|
193
203
|
errors,
|
|
194
204
|
warnings: levelCount(input.console, 'warn'),
|
|
@@ -328,6 +338,19 @@ export const shotSummary = (verdict: ShotVerdict): string => {
|
|
|
328
338
|
first: verdict.pageErrors[0]?.message ?? '',
|
|
329
339
|
});
|
|
330
340
|
}
|
|
341
|
+
// Ahead of the console count for the same reason, and it is the same silence: a rejected mount
|
|
342
|
+
// promise calls no console method either, so a page whose every island died can read `errors: 0`.
|
|
343
|
+
// The first failure is NAMED — "1 island failed" sends a reader back to the artifact for the one
|
|
344
|
+
// fact they need to start.
|
|
345
|
+
const failure = verdict.islands?.failures[0];
|
|
346
|
+
if (failure !== undefined) {
|
|
347
|
+
return msg('cli.shot.islandFailed', {
|
|
348
|
+
route: verdict.route,
|
|
349
|
+
failed: verdict.islands?.failed ?? 0,
|
|
350
|
+
island: failure.island,
|
|
351
|
+
message: failure.message,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
331
354
|
if (verdict.errors > 0) {
|
|
332
355
|
return msg('cli.shot.errors', { route: verdict.route, errors: verdict.errors });
|
|
333
356
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// per-tenant subscription cap all decided against an anonymous actor. Realtime was single-tenant
|
|
5
5
|
// by wiring, not by design.
|
|
6
6
|
|
|
7
|
-
import type { Actor } from '@ultimat3/core';
|
|
7
|
+
import type { Actor, Clock } from '@ultimat3/core';
|
|
8
|
+
import { systemClock } from '@ultimat3/core';
|
|
8
9
|
import type { HttpConfig } from '@ultimat3/http';
|
|
9
10
|
import {
|
|
10
11
|
configuredAuthenticator,
|
|
@@ -12,7 +13,37 @@ import {
|
|
|
12
13
|
defineHttpConfig,
|
|
13
14
|
UltimateRequest,
|
|
14
15
|
} from '@ultimat3/http';
|
|
15
|
-
import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime';
|
|
16
|
+
import type { SyncAuthenticator, SyncGrant } from '@ultimat3/realtime/server';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How long one grant stands before the node re-decides it.
|
|
20
|
+
*
|
|
21
|
+
* A grant with no expiry never appears in `GrantBook.expired()`, so `sweepGrants` — the only path
|
|
22
|
+
* to `hub.onActorChange` and `registry.reauthorize` — never fired for a socket this adapter opened.
|
|
23
|
+
* `logout`, `revokeSession`, `disableUser` and `updatePrivileges` closed the HTTP session and never
|
|
24
|
+
* the websocket, and the client's 15s heartbeat beats the 120s idle sweep, so the socket stayed up
|
|
25
|
+
* with the revoked actor's authority for as long as the tab was open.
|
|
26
|
+
*
|
|
27
|
+
* Five minutes, against `DEFAULT_REAUTH_INTERVAL_MS` (30s): the window a revoked actor keeps its
|
|
28
|
+
* socket is this plus one sweep, and the cost is one resolver call per socket per window — 167/s
|
|
29
|
+
* on the 50,000-socket node this repo has measured, against 1,667/s at a 30s TTL. A deployment
|
|
30
|
+
* whose credential has a shorter real lifetime passes `runtime.syncAuthenticate` and states it.
|
|
31
|
+
*/
|
|
32
|
+
export const SYNC_GRANT_TTL_MS = 5 * 60_000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The credential this adapter retains per socket, and nothing else.
|
|
36
|
+
*
|
|
37
|
+
* `sync-auth.ts` says the seam is a closure precisely so a node does not hold one `Request` per
|
|
38
|
+
* connection for the life of that connection: `SyncSocket`'s budget is ~1KB and the grant sits
|
|
39
|
+
* beside it. Two header values is what an app that closes over a token string would hold.
|
|
40
|
+
*
|
|
41
|
+
* Dropping the rest can only make a refresh MORE restrictive, never more permissive: an app that
|
|
42
|
+
* resolves identity from some other header sees its refresh answer `null`, which closes the socket
|
|
43
|
+
* with `1008` and the client re-dials carrying that header again. One reconnect per window, not an
|
|
44
|
+
* escalation — and `runtime.syncAuthenticate` is the declared seam for stating something else.
|
|
45
|
+
*/
|
|
46
|
+
const CREDENTIAL_HEADERS = ['cookie', 'authorization'] as const;
|
|
16
47
|
|
|
17
48
|
/**
|
|
18
49
|
* The upgrade request, dressed as the request an `Authenticator` reads.
|
|
@@ -27,33 +58,74 @@ function upgradeConfig(buildId: string): HttpConfig {
|
|
|
27
58
|
return defineHttpConfig({ buildId, rateLimit: { enabled: false, scope: 'process' } });
|
|
28
59
|
}
|
|
29
60
|
|
|
61
|
+
/** What the closure keeps: enough to ask the app's resolver the same question a second time. */
|
|
62
|
+
interface Credential {
|
|
63
|
+
readonly url: string;
|
|
64
|
+
readonly method: string;
|
|
65
|
+
readonly headers: Headers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function credentialOf(request: Request): Credential {
|
|
69
|
+
const headers = new Headers();
|
|
70
|
+
for (const name of CREDENTIAL_HEADERS) {
|
|
71
|
+
const value = request.headers.get(name);
|
|
72
|
+
if (value !== null) headers.set(name, value);
|
|
73
|
+
}
|
|
74
|
+
return { url: request.url, method: request.method, headers };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SyncAuthenticatorOptions {
|
|
78
|
+
/** The clock a grant's window is measured on. Injected so a re-auth is provable without sleeping. */
|
|
79
|
+
readonly clock?: Clock;
|
|
80
|
+
/** Overrides `SYNC_GRANT_TTL_MS`. A test names its own window; nothing in the boot passes one. */
|
|
81
|
+
readonly ttlMs?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
30
84
|
/**
|
|
31
85
|
* What the sync node is given when the app configured an authenticator, and `undefined` when it
|
|
32
86
|
* did not — which keeps `x dev` anonymous and makes the node log that it is, exactly as
|
|
33
87
|
* `createSyncNode` documents. A stub that answered `{ actor: anonymous }` would look configured.
|
|
34
88
|
*
|
|
35
|
-
* The grant carries
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* already lives in `createSyncNode.start()`.
|
|
89
|
+
* The grant carries an `expiresAt` and a `refresh`, and both are the app's own resolver asked
|
|
90
|
+
* again: `configureAuthenticator` says who is dialling, and the only honest way to learn that it
|
|
91
|
+
* has stopped being true is to ask. A `null` second answer is a revocation the node turns into a
|
|
92
|
+
* `1008`; a THROW is a backend failure, and `sweepGrants` keeps the grant and retries — the
|
|
93
|
+
* adapter must not collapse those two, here or on the refresh path.
|
|
41
94
|
*/
|
|
42
|
-
export function syncAuthenticator(
|
|
95
|
+
export function syncAuthenticator(
|
|
96
|
+
buildId: string,
|
|
97
|
+
options: SyncAuthenticatorOptions = {},
|
|
98
|
+
): SyncAuthenticator | undefined {
|
|
43
99
|
const authenticate = configuredAuthenticator();
|
|
44
100
|
if (authenticate === undefined) return undefined;
|
|
45
101
|
// Once per node, not once per upgrade: resolving a config is pure and a 50k-socket node pays
|
|
46
102
|
// this per connection otherwise.
|
|
47
103
|
const config = upgradeConfig(buildId);
|
|
48
|
-
|
|
104
|
+
const clock = options.clock ?? systemClock;
|
|
105
|
+
const ttlMs = options.ttlMs ?? SYNC_GRANT_TTL_MS;
|
|
106
|
+
|
|
107
|
+
const resolve = async (credential: Credential): Promise<SyncGrant | null> => {
|
|
108
|
+
const request = new Request(credential.url, {
|
|
109
|
+
method: credential.method,
|
|
110
|
+
headers: credential.headers,
|
|
111
|
+
});
|
|
49
112
|
const ctx = createRequestContext({
|
|
50
|
-
url: new URL(
|
|
51
|
-
method:
|
|
113
|
+
url: new URL(credential.url),
|
|
114
|
+
method: credential.method,
|
|
52
115
|
role: 'sync',
|
|
53
116
|
config,
|
|
54
|
-
requestHeaders:
|
|
117
|
+
requestHeaders: credential.headers,
|
|
55
118
|
});
|
|
56
119
|
const actor: Actor | null = await authenticate(new UltimateRequest(request, ctx), ctx);
|
|
57
|
-
|
|
120
|
+
if (actor === null) return null;
|
|
121
|
+
return {
|
|
122
|
+
// The window is measured from the answer, not from the upgrade: a refreshed grant that
|
|
123
|
+
// returned its original instant would be expired again on the very next pass.
|
|
124
|
+
actor,
|
|
125
|
+
expiresAt: clock.now().getTime() + ttlMs,
|
|
126
|
+
refresh: () => resolve(credential),
|
|
127
|
+
};
|
|
58
128
|
};
|
|
129
|
+
|
|
130
|
+
return async (request: Request): Promise<SyncGrant | null> => resolve(credentialOf(request));
|
|
59
131
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// The `bare-error` guard `x new` ships: no shipped module throws a bare `Error`.
|
|
2
|
+
// `AGENTS.md` has always stated the rule and NOTHING enforced it — `throw new Error(...)` in a
|
|
3
|
+
// scaffolded `repo.ts` was green on `x verify`, and it reaches an agent as a stack trace with no
|
|
4
|
+
// code, no cause and nothing to run.
|
|
5
|
+
|
|
6
|
+
import { guardCode } from './guard';
|
|
7
|
+
import type { GeneratedFile } from './naming';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
|
|
11
|
+
* An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
|
|
12
|
+
* registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
|
|
13
|
+
* own conventions raise, so this one is spelled by the file it lands in and nowhere else.
|
|
14
|
+
*/
|
|
15
|
+
const NAME = 'bare-error';
|
|
16
|
+
const CODE = guardCode(NAME);
|
|
17
|
+
|
|
18
|
+
const source =
|
|
19
|
+
(): string => `// bare-error: a failure this app raises carries a code, a cause and an executable fix.
|
|
20
|
+
// \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
|
|
21
|
+
// step — nothing registers this file, so nothing can forget to.
|
|
22
|
+
|
|
23
|
+
import type { Finding, Guard } from '@ultimat3/cli';
|
|
24
|
+
|
|
25
|
+
/** The app owns the codes its own conventions raise — this one is named for the guard. */
|
|
26
|
+
const CODE = '${CODE}';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A THROW, never a construction. \`new Error(…)\` handed to something as INPUT is legitimate — a
|
|
30
|
+
* test fixture, an \`AbortSignal\` reason, a rejection this module is passing along — and only the
|
|
31
|
+
* throw is this module stating its own verdict.
|
|
32
|
+
*/
|
|
33
|
+
const BARE_THROW = /\\bthrow\\s+new\\s+(Error|TypeError|RangeError|SyntaxError)\\s*\\(/g;
|
|
34
|
+
|
|
35
|
+
export interface SourceFile {
|
|
36
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
37
|
+
readonly path: string;
|
|
38
|
+
readonly source: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
|
|
42
|
+
const blank = (text: string): string =>
|
|
43
|
+
text
|
|
44
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
45
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
|
|
46
|
+
|
|
47
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
48
|
+
|
|
49
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
50
|
+
export function bareThrows(files: readonly SourceFile[]): readonly Finding[] {
|
|
51
|
+
const findings: Finding[] = [];
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const text = blank(file.source);
|
|
54
|
+
for (const match of text.matchAll(BARE_THROW)) {
|
|
55
|
+
const line = lineOf(text, match.index);
|
|
56
|
+
const thrown = match[1] ?? 'Error';
|
|
57
|
+
findings.push({
|
|
58
|
+
code: CODE,
|
|
59
|
+
cause: \`\${file.path}:\${line} throws a bare \${thrown} — it reaches its reader as a stack trace with no code, no cause and nothing to run\`,
|
|
60
|
+
fix: \`subclass UltimateError in \${file.path} with an X_SCREAMING_SNAKE code, a cause and a fix naming a command, then: x verify\`,
|
|
61
|
+
at: file.path,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return findings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const guard: Guard = {
|
|
69
|
+
summary: 'a failure carries a code, a cause and an executable fix — never a bare Error',
|
|
70
|
+
async check(root) {
|
|
71
|
+
const files: SourceFile[] = [];
|
|
72
|
+
for await (const entry of new Bun.Glob('{apps,packages}/**/*.{ts,tsx}').scan({
|
|
73
|
+
cwd: root,
|
|
74
|
+
absolute: false,
|
|
75
|
+
})) {
|
|
76
|
+
const path = entry.split('\\\\').join('/');
|
|
77
|
+
// A test states its verdict with \`expect.unreachable()\`, which the suite reports on its own
|
|
78
|
+
// terms; \`node_modules\` is not this app's source.
|
|
79
|
+
if (path.includes('node_modules/') || /\\.(?:test|d)\\.tsx?$/.test(path)) continue;
|
|
80
|
+
files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
81
|
+
}
|
|
82
|
+
return bareThrows(files);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
const test =
|
|
88
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
89
|
+
// a green gate over the convention it was written to enforce.
|
|
90
|
+
|
|
91
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
92
|
+
import { bareThrows } from './bare-error';
|
|
93
|
+
|
|
94
|
+
const file = (source: string) => [{ path: 'apps/web/app/post/repo.ts', source }];
|
|
95
|
+
|
|
96
|
+
unitTest('a bare throw is refused, and the finding names the line', () => {
|
|
97
|
+
const findings = bareThrows(file("const x = 1;\\nthrow new Error('no post');"));
|
|
98
|
+
expect(findings).toHaveLength(1);
|
|
99
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
100
|
+
expect(findings[0]?.cause).toContain(':2');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
unitTest('TypeError and RangeError are the same rule', () => {
|
|
104
|
+
expect(bareThrows(file("throw new TypeError('x');"))).toHaveLength(1);
|
|
105
|
+
expect(bareThrows(file("throw new RangeError('x');"))).toHaveLength(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
unitTest('an UltimateError subclass is what the rule asks for', () => {
|
|
109
|
+
expect(bareThrows(file('throw new PostError(missingPost(id));'))).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
unitTest('a bare Error that is INPUT is not a verdict', () => {
|
|
113
|
+
expect(bareThrows(file("controller.abort(new Error('cancelled'));"))).toEqual([]);
|
|
114
|
+
expect(bareThrows(file("// throw new Error('x');"))).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
/** `guards/bare-error.ts` and its test. No index, no registry — the directory registers it. */
|
|
119
|
+
export const bareErrorGuardFiles = (): readonly GeneratedFile[] => [
|
|
120
|
+
{ path: 'guards/bare-error.ts', contents: source() },
|
|
121
|
+
{ path: 'guards/bare-error.test.ts', contents: test() },
|
|
122
|
+
];
|