@ultimat3/cli 20.1.3 → 20.1.5
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/package.json +29 -29
- package/src/cmd-dev.ts +3 -3
- package/src/cmd-mcp.ts +2 -2
- package/src/cmd-shot.ts +8 -1
- package/src/dev-sync.ts +14 -4
- package/src/error-codes.ts +5 -0
- package/src/mcp-errors.ts +3 -0
- package/src/mcp-host.ts +20 -3
- package/src/mcp-ui.ts +172 -0
- package/src/output.ts +1 -1
- package/src/templates/guard-unzoned-date.ts +18 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "20.1.
|
|
3
|
+
"version": "20.1.5",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "20.1.
|
|
41
|
-
"@ultimat3/admin": "20.1.
|
|
42
|
-
"@ultimat3/ai": "20.1.
|
|
43
|
-
"@ultimat3/auth": "20.1.
|
|
44
|
-
"@ultimat3/cache": "20.1.
|
|
45
|
-
"@ultimat3/core": "20.1.
|
|
46
|
-
"@ultimat3/db": "20.1.
|
|
47
|
-
"@ultimat3/entity": "20.1.
|
|
48
|
-
"@ultimat3/flags": "20.1.
|
|
49
|
-
"@ultimat3/http": "20.1.
|
|
50
|
-
"@ultimat3/i18n": "20.1.
|
|
51
|
-
"@ultimat3/jobs": "20.1.
|
|
52
|
-
"@ultimat3/mail": "20.1.
|
|
53
|
-
"@ultimat3/manifest": "20.1.
|
|
54
|
-
"@ultimat3/mcp": "20.1.
|
|
55
|
-
"@ultimat3/money": "20.1.
|
|
56
|
-
"@ultimat3/notify": "20.1.
|
|
57
|
-
"@ultimat3/policy": "20.1.
|
|
58
|
-
"@ultimat3/pwa": "20.1.
|
|
59
|
-
"@ultimat3/query": "20.1.
|
|
60
|
-
"@ultimat3/realtime": "20.1.
|
|
61
|
-
"@ultimat3/render": "20.1.
|
|
62
|
-
"@ultimat3/schema": "20.1.
|
|
63
|
-
"@ultimat3/scraping": "20.1.
|
|
64
|
-
"@ultimat3/seo": "20.1.
|
|
65
|
-
"@ultimat3/storage": "20.1.
|
|
66
|
-
"@ultimat3/testing": "20.1.
|
|
67
|
-
"@ultimat3/time": "20.1.
|
|
40
|
+
"@ultimat3/action": "20.1.5",
|
|
41
|
+
"@ultimat3/admin": "20.1.5",
|
|
42
|
+
"@ultimat3/ai": "20.1.5",
|
|
43
|
+
"@ultimat3/auth": "20.1.5",
|
|
44
|
+
"@ultimat3/cache": "20.1.5",
|
|
45
|
+
"@ultimat3/core": "20.1.5",
|
|
46
|
+
"@ultimat3/db": "20.1.5",
|
|
47
|
+
"@ultimat3/entity": "20.1.5",
|
|
48
|
+
"@ultimat3/flags": "20.1.5",
|
|
49
|
+
"@ultimat3/http": "20.1.5",
|
|
50
|
+
"@ultimat3/i18n": "20.1.5",
|
|
51
|
+
"@ultimat3/jobs": "20.1.5",
|
|
52
|
+
"@ultimat3/mail": "20.1.5",
|
|
53
|
+
"@ultimat3/manifest": "20.1.5",
|
|
54
|
+
"@ultimat3/mcp": "20.1.5",
|
|
55
|
+
"@ultimat3/money": "20.1.5",
|
|
56
|
+
"@ultimat3/notify": "20.1.5",
|
|
57
|
+
"@ultimat3/policy": "20.1.5",
|
|
58
|
+
"@ultimat3/pwa": "20.1.5",
|
|
59
|
+
"@ultimat3/query": "20.1.5",
|
|
60
|
+
"@ultimat3/realtime": "20.1.5",
|
|
61
|
+
"@ultimat3/render": "20.1.5",
|
|
62
|
+
"@ultimat3/schema": "20.1.5",
|
|
63
|
+
"@ultimat3/scraping": "20.1.5",
|
|
64
|
+
"@ultimat3/seo": "20.1.5",
|
|
65
|
+
"@ultimat3/storage": "20.1.5",
|
|
66
|
+
"@ultimat3/testing": "20.1.5",
|
|
67
|
+
"@ultimat3/time": "20.1.5",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/cmd-dev.ts
CHANGED
|
@@ -128,6 +128,9 @@ const envOf = (env: StartDevOptions['env']): { env?: string } => {
|
|
|
128
128
|
* still be reachable while something is broken.
|
|
129
129
|
*/
|
|
130
130
|
export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
131
|
+
// EVERY boot: a scratch server (`x shot`, `ui.shot`) boots here too, and without this a
|
|
132
|
+
// fail-closed dev actor installs nothing — the picture is of a 401. Idempotent.
|
|
133
|
+
declareDevEnvironment(options.env);
|
|
131
134
|
const services = resolveServices(options.root, options.env);
|
|
132
135
|
const runtime: RunningServices = await startServices(services, options.env);
|
|
133
136
|
// Installed before the app loads, so a span opened during registration is already recorded.
|
|
@@ -375,9 +378,6 @@ export const devCommand: CliCommand = {
|
|
|
375
378
|
},
|
|
376
379
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
377
380
|
const root = requireAppRoot('dev', ctx.cwd).dir;
|
|
378
|
-
// BEFORE `startDev` imports a single app module — see `dev-environment.ts` for why this must
|
|
379
|
-
// run this early, and why it mutates the real `process.env` rather than `startDev`'s `env`.
|
|
380
|
-
declareDevEnvironment(ctx.env);
|
|
381
381
|
// Validated, not `parseInt`'d: `x dev --port abc` handed `NaN` to `Bun.serve`, which binds an
|
|
382
382
|
// arbitrary port — a dev server reachable at an address nothing printed.
|
|
383
383
|
const port = intFlagOr(
|
package/src/cmd-mcp.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `x mcp serve` — the framework's dev MCP server over stdio or HTTP. The
|
|
1
|
+
// `x mcp serve` — the framework's dev MCP server over stdio or HTTP. The 15 tools, the JSON-RPC
|
|
2
2
|
// dispatch, both transports and the structural SQL refusals all come from `@ultimat3/mcp`; the CLI
|
|
3
3
|
// supplies only the app, the caller and the socket. A tool answered here would be a second answer
|
|
4
4
|
// to a question the framework already answers.
|
|
@@ -117,7 +117,7 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
|
|
|
117
117
|
* What the session reports when it is over — on STDERR, which is the half this file's header
|
|
118
118
|
* claimed and did not have. `dispatch` renders a `CommandResult` only after `run` resolves, and
|
|
119
119
|
* this resolves when the peer closes stdin, so nothing lands mid-session; but fd 1 under this
|
|
120
|
-
* transport carries JSON-RPC frames, and `✓ mcp stdio serving
|
|
120
|
+
* transport carries JSON-RPC frames, and `✓ mcp stdio serving 15 tools` arriving on it after the
|
|
121
121
|
* loop is a malformed frame to a peer still draining, and a second document under `--json`.
|
|
122
122
|
*
|
|
123
123
|
* Its own function so the addressing is testable without a live peer: `serveStdio` resolves only
|
package/src/cmd-shot.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { mkdirSync } from 'node:fs';
|
|
9
9
|
import { join, resolve } from 'node:path';
|
|
10
10
|
import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
11
|
-
import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
11
|
+
import type { ColorScheme, ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
12
12
|
import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
|
|
13
13
|
import { requireAppRoot } from './app-root';
|
|
14
14
|
import { appBrowser } from './browser-launcher';
|
|
@@ -190,6 +190,12 @@ export interface ShotRun {
|
|
|
190
190
|
* with `--port 0` the port — and therefore the origin — does not exist until after the boot.
|
|
191
191
|
*/
|
|
192
192
|
readonly extraHosts?: string | undefined;
|
|
193
|
+
/**
|
|
194
|
+
* What `prefers-color-scheme` the page sees, emulated BEFORE navigation so the boot script's
|
|
195
|
+
* "system" branch answers the same on every box. Absent means the box's own preference — what
|
|
196
|
+
* `x shot` has always done — and `ui.shot` names one explicitly for exactly that reason.
|
|
197
|
+
*/
|
|
198
|
+
readonly colorScheme?: ColorScheme | undefined;
|
|
193
199
|
readonly now?: (() => Date) | undefined;
|
|
194
200
|
}
|
|
195
201
|
|
|
@@ -219,6 +225,7 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
219
225
|
timeoutMs: options.timeoutMs,
|
|
220
226
|
});
|
|
221
227
|
const page = session.page;
|
|
228
|
+
if (options.colorScheme !== undefined) await page.colorScheme(options.colorScheme);
|
|
222
229
|
await page.goto(requestedUrl, { timeout: options.timeoutMs });
|
|
223
230
|
if (options.settleMs > 0) await Bun.sleep(options.settleMs);
|
|
224
231
|
// The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
|
package/src/dev-sync.ts
CHANGED
|
@@ -224,17 +224,27 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
224
224
|
* neighbouring port, and reversing the order would answer a second `x dev` on this checkout with
|
|
225
225
|
* "port 3001 is in use" when the fact worth printing is that 3000 is.
|
|
226
226
|
*
|
|
227
|
-
* Port 0 is passed
|
|
228
|
-
*
|
|
229
|
-
*
|
|
227
|
+
* Port 0 is NOT passed through to the kernel. It cannot be incremented (`+ 1` would ask for port
|
|
228
|
+
* 1), but the web role has bound by the time this runs and `appUrl` carries the port it got — and
|
|
229
|
+
* `PORT + 1` is the contract every scaffolded `sync-url.ts` computes from and the wiki states. A
|
|
230
|
+
* scratch server (`x shot`, `ui.shot`) always asks for 0, so before this every one of its
|
|
231
|
+
* pictures carried `WebSocket … ERR_CONNECTION_REFUSED` for every live island, on the framework's
|
|
232
|
+
* own account. With no web role to follow, 0 still goes to the kernel. The reported url is the
|
|
233
|
+
* listener's own bound address either way, never a string built from the port that was requested.
|
|
230
234
|
*/
|
|
235
|
+
function syncPortFrom(requested: number, appUrl: string | null): number {
|
|
236
|
+
if (requested !== 0 || appUrl === null) return syncPortFor(requested);
|
|
237
|
+
const bound = Number(new URL(appUrl).port);
|
|
238
|
+
return Number.isInteger(bound) && bound > 0 ? syncPortFor(bound) : 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
231
241
|
async function listen(
|
|
232
242
|
options: StartRolesOptions,
|
|
233
243
|
node: SyncNode,
|
|
234
244
|
registry: LiveQueryRegistry,
|
|
235
245
|
appUrl: string | null,
|
|
236
246
|
): Promise<RunningSync> {
|
|
237
|
-
const port =
|
|
247
|
+
const port = syncPortFrom(options.port, appUrl);
|
|
238
248
|
try {
|
|
239
249
|
// The SAME interface the web role binds, resolved from the same option and the same default.
|
|
240
250
|
// Without this the sync node took Bun's `0.0.0.0` while `x dev`'s web role took `localhost`,
|
package/src/error-codes.ts
CHANGED
|
@@ -154,6 +154,9 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
154
154
|
// repair is an install, and only `duplicate-packages.ts` can see that.
|
|
155
155
|
'X_PACKAGE_DUPLICATED',
|
|
156
156
|
'X_SHOT_BROWSER_MISSING',
|
|
157
|
+
// `ui.shot` (the dev MCP server): a route it will not photograph, and why.
|
|
158
|
+
'X_UI_SHOT_ROUTE_UNKNOWN',
|
|
159
|
+
'X_UI_SHOT_ROUTE_UNBUDGETED',
|
|
157
160
|
// `x shot --island` — one code per way a component's named state fails to become a picture.
|
|
158
161
|
// The last of the four is the one that gates: it is checked against the expansion computed
|
|
159
162
|
// before a browser existed, so a capture loop that swallowed a failure cannot exit 0.
|
|
@@ -297,6 +300,8 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
297
300
|
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
298
301
|
X_PACKAGE_DUPLICATED: 'two copies of one registry-holding framework package are installed',
|
|
299
302
|
X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
|
|
303
|
+
X_UI_SHOT_ROUTE_UNKNOWN: 'ui.shot was asked for a path no route answers',
|
|
304
|
+
X_UI_SHOT_ROUTE_UNBUDGETED: 'ui.shot refused a route that declares no budget.js',
|
|
300
305
|
X_SHOT_ISLAND_STATES_EMPTY: 'an island states file declares no manifest',
|
|
301
306
|
X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
|
|
302
307
|
X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
|
package/src/mcp-errors.ts
CHANGED
|
@@ -55,6 +55,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
55
55
|
X_PACKAGE_DUPLICATED:
|
|
56
56
|
'x i18n check --json # the finding names both copies and the package.json to pin',
|
|
57
57
|
X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
|
|
58
|
+
X_UI_SHOT_ROUTE_UNKNOWN: 'x routes --json # then ui.shot with one of its path values',
|
|
59
|
+
X_UI_SHOT_ROUTE_UNBUDGETED:
|
|
60
|
+
"x build --target static --json && x verify --only budgets --json # after declaring budget: { js: '<n>kb' } in the route file",
|
|
58
61
|
// The four island-capture codes. Each one's real repair is an edit to the app's own states file
|
|
59
62
|
// or component, which no command can perform — so each names the command that REPRODUCES it with
|
|
60
63
|
// the file and the reason attached, which is the runnable half.
|
package/src/mcp-host.ts
CHANGED
|
@@ -44,6 +44,7 @@ import { execOutput } from './exec';
|
|
|
44
44
|
import { databaseTarget } from './mcp-db-target';
|
|
45
45
|
import { explainErrorCode } from './mcp-errors';
|
|
46
46
|
import { parseBunTest } from './mcp-test-output';
|
|
47
|
+
import { type UiCapabilities, uiCapabilities } from './mcp-ui';
|
|
47
48
|
import { readMigrations } from './migrations';
|
|
48
49
|
import { retryMemo } from './retry-memo';
|
|
49
50
|
import { testEnvOverrides } from './test-dotenv';
|
|
@@ -178,7 +179,11 @@ export async function readOnlyRows(
|
|
|
178
179
|
return { columns, rows: kept.map((row) => columns.map((column) => row[column])), guards };
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
function capabilities(
|
|
182
|
+
function capabilities(
|
|
183
|
+
input: DevHostInput,
|
|
184
|
+
lazy: LazyServices,
|
|
185
|
+
ui: UiCapabilities,
|
|
186
|
+
): DevCapabilities {
|
|
182
187
|
const { root, runner, env } = input;
|
|
183
188
|
// Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
|
|
184
189
|
// loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
|
|
@@ -281,6 +286,8 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
281
286
|
|
|
282
287
|
explainError: explainErrorCode,
|
|
283
288
|
|
|
289
|
+
...ui,
|
|
290
|
+
|
|
284
291
|
async verify(fix: boolean): Promise<VerifyResult> {
|
|
285
292
|
// The one safe autofix this repo actually has. Anything more would be the gate rewriting
|
|
286
293
|
// code it was asked to judge.
|
|
@@ -310,11 +317,21 @@ export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpSer
|
|
|
310
317
|
// every code it could have quoted.
|
|
311
318
|
await Promise.all([loadApp(input.root), loadCodeFixes()]);
|
|
312
319
|
const lazy = lazyServices(input);
|
|
320
|
+
const ui = uiCapabilities({ root: input.root, env: input.env });
|
|
313
321
|
const introspection = frameworkIntrospection({
|
|
314
322
|
routes: () => describeRoutes(),
|
|
315
323
|
policies: () => policyFacts(),
|
|
316
324
|
});
|
|
317
|
-
const server = createDevServer({ host: devHost(introspection, capabilities(input, lazy)) });
|
|
325
|
+
const server = createDevServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
|
|
318
326
|
const caller = localCaller();
|
|
319
|
-
return {
|
|
327
|
+
return {
|
|
328
|
+
server,
|
|
329
|
+
caller,
|
|
330
|
+
tools: server.tools.names(caller),
|
|
331
|
+
// The scratch server first: it holds the same embedded database the lazy services would.
|
|
332
|
+
close: async () => {
|
|
333
|
+
await ui.close();
|
|
334
|
+
await lazy.close();
|
|
335
|
+
},
|
|
336
|
+
};
|
|
320
337
|
}
|
package/src/mcp-ui.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// The dev MCP server's two eyes: `ui.shot` (a route) and `ui.island` (a component's states), as
|
|
2
|
+
// the `DevCapabilities` half `packages/mcp` declares and cannot satisfy — a browser is the CLI's
|
|
3
|
+
// to launch. Both are `x shot` under another name: the same server lookup (a running `x dev` is
|
|
4
|
+
// reused through its lock, otherwise a scratch one boots), the same driver, the same verdict.
|
|
5
|
+
// Nothing here is a new capability; it is the existing one made reachable from inside the loop
|
|
6
|
+
// an agent already works in, so "does it look right" stops needing a hand-written script.
|
|
7
|
+
|
|
8
|
+
// why: Bun exposes no path-join primitive, and the picture's directory is a path an agent opens.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { UltimateError } from '@ultimat3/core';
|
|
11
|
+
import type { UiIslandInput, UiIslandResult, UiShotInput, UiShotResult } from '@ultimat3/mcp';
|
|
12
|
+
import { describeRoutes } from '@ultimat3/render';
|
|
13
|
+
import type { ScrapeDriver } from '@ultimat3/scraping';
|
|
14
|
+
import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
|
|
15
|
+
import { appBrowser } from './browser-launcher';
|
|
16
|
+
import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
|
|
17
|
+
import { islandShot } from './cmd-shot-island';
|
|
18
|
+
import type { Env } from './dev-services';
|
|
19
|
+
import { islandVerdictJson } from './island-verdict';
|
|
20
|
+
import { retryMemo } from './retry-memo';
|
|
21
|
+
import { shotBrowserChoice } from './shot-browser';
|
|
22
|
+
import { devServerFor, type ShotServer } from './shot-server';
|
|
23
|
+
import { verdictJson } from './shot-verdict';
|
|
24
|
+
|
|
25
|
+
/** Kernel-picked, as `x shot` picks it: a scratch server never fights another project for :3000. */
|
|
26
|
+
const SCRATCH_PORT = 0;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The route a picture is of has to be a route this app declares WITH a JS budget. The gate
|
|
30
|
+
* refuses an unmeasured route (`X_BUDGET_UNMEASURED`), and a picture of a route nobody has
|
|
31
|
+
* finished declaring is a picture of a draft — an agent judging it would judge the wrong thing.
|
|
32
|
+
* Matched on the declared pattern, so `/links/abc123` finds `/links/:slug`.
|
|
33
|
+
*/
|
|
34
|
+
export interface DeclaredRoute {
|
|
35
|
+
readonly path: string;
|
|
36
|
+
readonly file: string;
|
|
37
|
+
readonly budgetJs: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function assertBudgetedRoute(route: string, declared: readonly DeclaredRoute[]): void {
|
|
41
|
+
const path = route.split('?')[0] ?? route;
|
|
42
|
+
const hit = declared.find((entry) => matches(entry.path, path));
|
|
43
|
+
if (hit === undefined) {
|
|
44
|
+
throw new UltimateError({
|
|
45
|
+
code: 'X_UI_SHOT_ROUTE_UNKNOWN',
|
|
46
|
+
cause: `no route in this app answers ${route}`,
|
|
47
|
+
fix: 'x routes --json # then ui.shot with one of its `path` values',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (hit.budgetJs === null) {
|
|
51
|
+
throw new UltimateError({
|
|
52
|
+
code: 'X_UI_SHOT_ROUTE_UNBUDGETED',
|
|
53
|
+
cause: `${hit.file} declares no budget.js, so x verify would refuse it as X_BUDGET_UNMEASURED — a picture of it would be a picture of a draft`,
|
|
54
|
+
fix: `declare budget: { js: '<n>kb' } in ${hit.file}, then: x build --target static --json && x verify --only budgets --json`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `/links/:slug` matches `/links/abc123`; one segment per `:param`, no globbing. */
|
|
60
|
+
export function matches(pattern: string, path: string): boolean {
|
|
61
|
+
const want = pattern.split('/');
|
|
62
|
+
const have = path.split('/');
|
|
63
|
+
if (want.length !== have.length) return false;
|
|
64
|
+
return want.every((segment, index) => segment.startsWith(':') || segment === have[index]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface UiHostInput {
|
|
68
|
+
readonly root: string;
|
|
69
|
+
readonly env: Env;
|
|
70
|
+
/** Injected by a test: the server a picture is of, in place of `devServerFor`. */
|
|
71
|
+
readonly boot?: (() => Promise<ShotServer>) | undefined;
|
|
72
|
+
/** Injected by a test: the browser, in place of `appBrowser` (which needs Chrome). */
|
|
73
|
+
readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<ScrapeDriver>) | undefined;
|
|
74
|
+
/** Injected by a test: the route table, in place of the registry. */
|
|
75
|
+
readonly routes?: (() => readonly DeclaredRoute[]) | undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface UiCapabilities {
|
|
79
|
+
shotRoute(shot: UiShotInput): Promise<UiShotResult>;
|
|
80
|
+
shotIsland(island: UiIslandInput): Promise<UiIslandResult>;
|
|
81
|
+
/** Stops the scratch server, if one was booted. Never boots one in order to stop it. */
|
|
82
|
+
close(): Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
86
|
+
const { root, env } = input;
|
|
87
|
+
// ONCE per process, never per call. `x shot` the command boots a server, photographs, and
|
|
88
|
+
// stops it — one process, one lifecycle. The dev MCP server is one process serving many calls,
|
|
89
|
+
// and a lifecycle drains exactly once: the second `ui.shot` in a session answered
|
|
90
|
+
// `X_LIFECYCLE_DRAINED`, the third `X_READINESS_CHECK_DUPLICATE`. So the scratch server (or the
|
|
91
|
+
// running `x dev` the lock names) is memoised for the host's life, `runShot` is handed a handle
|
|
92
|
+
// whose `stop` is a no-op, and `close()` is what stops it. A boot that rejected is retried on
|
|
93
|
+
// the next call, and has nothing to stop.
|
|
94
|
+
const scratch = retryMemo(() => (input.boot ?? (() => devServerFor(root, env, SCRATCH_PORT)))());
|
|
95
|
+
const boot = async (): Promise<ShotServer> => {
|
|
96
|
+
const server = await scratch.get();
|
|
97
|
+
return { url: server.url, origin: server.origin, stop: () => Promise.resolve() };
|
|
98
|
+
};
|
|
99
|
+
// The same choice `x shot` makes from the environment: `PUPPETEER_EXECUTABLE_PATH`, a
|
|
100
|
+
// provider's CDP URL, or the launcher's own discovery.
|
|
101
|
+
const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
|
|
102
|
+
const routes = input.routes ?? describeRoutes;
|
|
103
|
+
let closed = false;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
async close() {
|
|
107
|
+
// Idempotent, as `lazyServices().close()` is: the host closes once, a test may close twice,
|
|
108
|
+
// and a server stopped twice is a second drain on a lifecycle that has none left.
|
|
109
|
+
if (closed) return;
|
|
110
|
+
closed = true;
|
|
111
|
+
await (await scratch.started()?.catch(() => undefined))?.stop();
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async shotRoute(shot) {
|
|
115
|
+
assertBudgetedRoute(shot.route, routes());
|
|
116
|
+
const { cdpUrl, executablePath } = browser();
|
|
117
|
+
const driver =
|
|
118
|
+
input.driver === undefined
|
|
119
|
+
? await appBrowser({
|
|
120
|
+
root,
|
|
121
|
+
viewport: shot.viewport,
|
|
122
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
123
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
124
|
+
})
|
|
125
|
+
: await input.driver(shot.viewport);
|
|
126
|
+
// One directory per (route, viewport, scheme), so two pictures of one route at two widths
|
|
127
|
+
// never overwrite each other and an agent can hold both.
|
|
128
|
+
const outDir = join(
|
|
129
|
+
root,
|
|
130
|
+
SHOT_DIR,
|
|
131
|
+
shotSlug(shot.route),
|
|
132
|
+
`${shot.viewport.width}x${shot.viewport.height}-${shot.colorScheme}`,
|
|
133
|
+
);
|
|
134
|
+
const artifacts = await runShot({
|
|
135
|
+
route: shot.route,
|
|
136
|
+
outDir,
|
|
137
|
+
driver,
|
|
138
|
+
boot,
|
|
139
|
+
settleMs: DEFAULT_SETTLE_MS,
|
|
140
|
+
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
141
|
+
fullPage: shot.fullPage,
|
|
142
|
+
colorScheme: shot.colorScheme,
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
ok: artifacts.verdict.ok,
|
|
146
|
+
image: artifacts.image,
|
|
147
|
+
verdictFile: artifacts.verdictFile,
|
|
148
|
+
verdict: verdictJson(artifacts.verdict),
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async shotIsland(island) {
|
|
153
|
+
const { cdpUrl, executablePath } = browser();
|
|
154
|
+
const artifacts = await islandShot({
|
|
155
|
+
root,
|
|
156
|
+
island: island.island,
|
|
157
|
+
...(island.state === undefined ? {} : { state: island.state }),
|
|
158
|
+
settleMs: DEFAULT_SETTLE_MS,
|
|
159
|
+
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
160
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
161
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
162
|
+
boot,
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
ok: artifacts.verdict.ok,
|
|
166
|
+
dir: artifacts.dir,
|
|
167
|
+
verdictFile: artifacts.verdictFile,
|
|
168
|
+
verdict: islandVerdictJson(artifacts.verdict),
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -64,7 +64,7 @@ export interface CommandResult {
|
|
|
64
64
|
* Which fd this result is written to. `stdout` for every command, absent included — and
|
|
65
65
|
* `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
|
|
66
66
|
* --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
|
|
67
|
-
*
|
|
67
|
+
* 15 tools` line printed after the loop exits is a malformed frame to whatever is reading.
|
|
68
68
|
*
|
|
69
69
|
* Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
|
|
70
70
|
* where a rendered line goes, and a payload that also claimed it would be a second answer to a
|
|
@@ -74,10 +74,17 @@ export function unzonedDates(files: readonly SourceFile[]): readonly Finding[] {
|
|
|
74
74
|
const open = match.index + match[0].length - 1;
|
|
75
75
|
if (argumentsOf(text, open).includes('timeZone')) continue;
|
|
76
76
|
const line = lineOf(text, match.index);
|
|
77
|
+
// The bare \`.toLocaleString(\` is ALSO \`Number.prototype.toLocaleString\`, and a regex cannot
|
|
78
|
+
// tell a count from a date. The match stands — a date formatted this way is the defect this
|
|
79
|
+
// guard exists for — but the fix names the number exit too, because \`{ timeZone }\` is not
|
|
80
|
+
// one: a number ignores it, and an author following the fix verbatim would ship a lie.
|
|
81
|
+
const bare = match[0].trim() === '.toLocaleString(';
|
|
77
82
|
findings.push({
|
|
78
83
|
code: CODE,
|
|
79
84
|
cause: \`\${file.path}:\${line} calls \${match[0].trim()}) with no timeZone — it formats in whatever zone the process happens to run in, so one row reads as two different days across two containers\`,
|
|
80
|
-
fix:
|
|
85
|
+
fix: bare
|
|
86
|
+
? \`in \${file.path}: a Date → pass an explicit IANA zone, at.toLocaleString(locale, { timeZone: 'UTC' }); a NUMBER → new Intl.NumberFormat(locale).format(n) instead — then: x verify\`
|
|
87
|
+
: \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
|
|
81
88
|
at: file.path,
|
|
82
89
|
});
|
|
83
90
|
}
|
|
@@ -130,6 +137,16 @@ unitTest('Intl.DateTimeFormat and toLocaleTimeString are the same rule', () => {
|
|
|
130
137
|
expect(unzonedDates(file("at.toLocaleTimeString('en-US');"))).toHaveLength(1);
|
|
131
138
|
});
|
|
132
139
|
|
|
140
|
+
unitTest('the bare toLocaleString names the number exit, since a count matches it too', () => {
|
|
141
|
+
const findings = unzonedDates(file("const shown = count.toLocaleString('en-US');"));
|
|
142
|
+
expect(findings).toHaveLength(1);
|
|
143
|
+
expect(findings[0]?.fix).toContain('Intl.NumberFormat');
|
|
144
|
+
expect(findings[0]?.fix).toContain('timeZone');
|
|
145
|
+
// The dated forms are unambiguous and keep the zone-only fix.
|
|
146
|
+
const dated = unzonedDates(file("at.toLocaleDateString('en-US');"));
|
|
147
|
+
expect(dated[0]?.fix).not.toContain('Intl.NumberFormat');
|
|
148
|
+
});
|
|
149
|
+
|
|
133
150
|
unitTest('a commented-out call is a note, not a call', () => {
|
|
134
151
|
expect(unzonedDates(file("// at.toLocaleDateString('en-US');"))).toEqual([]);
|
|
135
152
|
});
|