@ultimat3/cli 20.1.3 → 20.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "20.1.3",
3
+ "version": "20.1.4",
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.3",
41
- "@ultimat3/admin": "20.1.3",
42
- "@ultimat3/ai": "20.1.3",
43
- "@ultimat3/auth": "20.1.3",
44
- "@ultimat3/cache": "20.1.3",
45
- "@ultimat3/core": "20.1.3",
46
- "@ultimat3/db": "20.1.3",
47
- "@ultimat3/entity": "20.1.3",
48
- "@ultimat3/flags": "20.1.3",
49
- "@ultimat3/http": "20.1.3",
50
- "@ultimat3/i18n": "20.1.3",
51
- "@ultimat3/jobs": "20.1.3",
52
- "@ultimat3/mail": "20.1.3",
53
- "@ultimat3/manifest": "20.1.3",
54
- "@ultimat3/mcp": "20.1.3",
55
- "@ultimat3/money": "20.1.3",
56
- "@ultimat3/notify": "20.1.3",
57
- "@ultimat3/policy": "20.1.3",
58
- "@ultimat3/pwa": "20.1.3",
59
- "@ultimat3/query": "20.1.3",
60
- "@ultimat3/realtime": "20.1.3",
61
- "@ultimat3/render": "20.1.3",
62
- "@ultimat3/schema": "20.1.3",
63
- "@ultimat3/scraping": "20.1.3",
64
- "@ultimat3/seo": "20.1.3",
65
- "@ultimat3/storage": "20.1.3",
66
- "@ultimat3/testing": "20.1.3",
67
- "@ultimat3/time": "20.1.3",
40
+ "@ultimat3/action": "20.1.4",
41
+ "@ultimat3/admin": "20.1.4",
42
+ "@ultimat3/ai": "20.1.4",
43
+ "@ultimat3/auth": "20.1.4",
44
+ "@ultimat3/cache": "20.1.4",
45
+ "@ultimat3/core": "20.1.4",
46
+ "@ultimat3/db": "20.1.4",
47
+ "@ultimat3/entity": "20.1.4",
48
+ "@ultimat3/flags": "20.1.4",
49
+ "@ultimat3/http": "20.1.4",
50
+ "@ultimat3/i18n": "20.1.4",
51
+ "@ultimat3/jobs": "20.1.4",
52
+ "@ultimat3/mail": "20.1.4",
53
+ "@ultimat3/manifest": "20.1.4",
54
+ "@ultimat3/mcp": "20.1.4",
55
+ "@ultimat3/money": "20.1.4",
56
+ "@ultimat3/notify": "20.1.4",
57
+ "@ultimat3/policy": "20.1.4",
58
+ "@ultimat3/pwa": "20.1.4",
59
+ "@ultimat3/query": "20.1.4",
60
+ "@ultimat3/realtime": "20.1.4",
61
+ "@ultimat3/render": "20.1.4",
62
+ "@ultimat3/schema": "20.1.4",
63
+ "@ultimat3/scraping": "20.1.4",
64
+ "@ultimat3/seo": "20.1.4",
65
+ "@ultimat3/storage": "20.1.4",
66
+ "@ultimat3/testing": "20.1.4",
67
+ "@ultimat3/time": "20.1.4",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
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 13 tools, the JSON-RPC
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 13 tools` arriving on it after the
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
@@ -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 { uiCapabilities } from './mcp-ui';
47
48
  import { readMigrations } from './migrations';
48
49
  import { retryMemo } from './retry-memo';
49
50
  import { testEnvOverrides } from './test-dotenv';
@@ -281,6 +282,8 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
281
282
 
282
283
  explainError: explainErrorCode,
283
284
 
285
+ ...uiCapabilities({ root, env }),
286
+
284
287
  async verify(fix: boolean): Promise<VerifyResult> {
285
288
  // The one safe autofix this repo actually has. Anything more would be the gate rewriting
286
289
  // code it was asked to judge.
package/src/mcp-ui.ts ADDED
@@ -0,0 +1,136 @@
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 { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
14
+ import { appBrowser } from './browser-launcher';
15
+ import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
16
+ import { islandShot } from './cmd-shot-island';
17
+ import type { Env } from './dev-services';
18
+ import { islandVerdictJson } from './island-verdict';
19
+ import { shotBrowserChoice } from './shot-browser';
20
+ import { devServerFor } from './shot-server';
21
+ import { verdictJson } from './shot-verdict';
22
+
23
+ /** Kernel-picked, as `x shot` picks it: a scratch server never fights another project for :3000. */
24
+ const SCRATCH_PORT = 0;
25
+
26
+ /**
27
+ * The route a picture is of has to be a route this app declares WITH a JS budget. The gate
28
+ * refuses an unmeasured route (`X_BUDGET_UNMEASURED`), and a picture of a route nobody has
29
+ * finished declaring is a picture of a draft — an agent judging it would judge the wrong thing.
30
+ * Matched on the declared pattern, so `/links/abc123` finds `/links/:slug`.
31
+ */
32
+ export interface DeclaredRoute {
33
+ readonly path: string;
34
+ readonly file: string;
35
+ readonly budgetJs: string | null;
36
+ }
37
+
38
+ export function assertBudgetedRoute(route: string, declared: readonly DeclaredRoute[]): void {
39
+ const path = route.split('?')[0] ?? route;
40
+ const hit = declared.find((entry) => matches(entry.path, path));
41
+ if (hit === undefined) {
42
+ throw new UltimateError({
43
+ code: 'X_UI_SHOT_ROUTE_UNKNOWN',
44
+ cause: `no route in this app answers ${route}`,
45
+ fix: 'x routes --json # then ui.shot with one of its `path` values',
46
+ });
47
+ }
48
+ if (hit.budgetJs === null) {
49
+ throw new UltimateError({
50
+ code: 'X_UI_SHOT_ROUTE_UNBUDGETED',
51
+ 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`,
52
+ fix: `declare budget: { js: '<n>kb' } in ${hit.file}, then: x build --target static --json && x verify --only budgets --json`,
53
+ });
54
+ }
55
+ }
56
+
57
+ /** `/links/:slug` matches `/links/abc123`; one segment per `:param`, no globbing. */
58
+ export function matches(pattern: string, path: string): boolean {
59
+ const want = pattern.split('/');
60
+ const have = path.split('/');
61
+ if (want.length !== have.length) return false;
62
+ return want.every((segment, index) => segment.startsWith(':') || segment === have[index]);
63
+ }
64
+
65
+ export interface UiHostInput {
66
+ readonly root: string;
67
+ readonly env: Env;
68
+ }
69
+
70
+ export function uiCapabilities(input: UiHostInput): {
71
+ shotRoute(shot: UiShotInput): Promise<UiShotResult>;
72
+ shotIsland(island: UiIslandInput): Promise<UiIslandResult>;
73
+ } {
74
+ const { root, env } = input;
75
+ const boot = () => devServerFor(root, env, SCRATCH_PORT);
76
+ // The same choice `x shot` makes from the environment: `PUPPETEER_EXECUTABLE_PATH`, a
77
+ // provider's CDP URL, or the launcher's own discovery.
78
+ const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
79
+
80
+ return {
81
+ async shotRoute(shot) {
82
+ assertBudgetedRoute(shot.route, describeRoutes());
83
+ const { cdpUrl, executablePath } = browser();
84
+ const driver = await appBrowser({
85
+ root,
86
+ viewport: shot.viewport,
87
+ ...(executablePath === undefined ? {} : { executablePath }),
88
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
89
+ });
90
+ // One directory per (route, viewport, scheme), so two pictures of one route at two widths
91
+ // never overwrite each other and an agent can hold both.
92
+ const outDir = join(
93
+ root,
94
+ SHOT_DIR,
95
+ shotSlug(shot.route),
96
+ `${shot.viewport.width}x${shot.viewport.height}-${shot.colorScheme}`,
97
+ );
98
+ const artifacts = await runShot({
99
+ route: shot.route,
100
+ outDir,
101
+ driver,
102
+ boot,
103
+ settleMs: DEFAULT_SETTLE_MS,
104
+ timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
105
+ fullPage: shot.fullPage,
106
+ colorScheme: shot.colorScheme,
107
+ });
108
+ return {
109
+ ok: artifacts.verdict.ok,
110
+ image: artifacts.image,
111
+ verdictFile: artifacts.verdictFile,
112
+ verdict: verdictJson(artifacts.verdict),
113
+ };
114
+ },
115
+
116
+ async shotIsland(island) {
117
+ const { cdpUrl, executablePath } = browser();
118
+ const artifacts = await islandShot({
119
+ root,
120
+ island: island.island,
121
+ ...(island.state === undefined ? {} : { state: island.state }),
122
+ settleMs: DEFAULT_SETTLE_MS,
123
+ timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
124
+ ...(executablePath === undefined ? {} : { executablePath }),
125
+ ...(cdpUrl === undefined ? {} : { cdpUrl }),
126
+ boot,
127
+ });
128
+ return {
129
+ ok: artifacts.verdict.ok,
130
+ dir: artifacts.dir,
131
+ verdictFile: artifacts.verdictFile,
132
+ verdict: islandVerdictJson(artifacts.verdict),
133
+ };
134
+ },
135
+ };
136
+ }
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
- * 13 tools` line printed after the loop exits is a malformed frame to whatever is reading.
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: \`pass an explicit IANA zone in \${file.path} — toLocaleDateString(locale, { timeZone: 'UTC' }) — then: x verify\`,
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
  });