@ultimat3/cli 20.1.4 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "20.1.4",
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.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",
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/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 straight through rather than incremented — `+ 1` would ask the kernel for
228
- * port 1 instead of an ephemeral one — and the reported url is the listener's own bound address,
229
- * never a string built from the port that was requested.
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 = syncPortFor(options.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/mcp-host.ts CHANGED
@@ -44,7 +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
+ import { type UiCapabilities, uiCapabilities } from './mcp-ui';
48
48
  import { readMigrations } from './migrations';
49
49
  import { retryMemo } from './retry-memo';
50
50
  import { testEnvOverrides } from './test-dotenv';
@@ -179,7 +179,11 @@ export async function readOnlyRows(
179
179
  return { columns, rows: kept.map((row) => columns.map((column) => row[column])), guards };
180
180
  }
181
181
 
182
- function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
182
+ function capabilities(
183
+ input: DevHostInput,
184
+ lazy: LazyServices,
185
+ ui: UiCapabilities,
186
+ ): DevCapabilities {
183
187
  const { root, runner, env } = input;
184
188
  // Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
185
189
  // loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
@@ -282,7 +286,7 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
282
286
 
283
287
  explainError: explainErrorCode,
284
288
 
285
- ...uiCapabilities({ root, env }),
289
+ ...ui,
286
290
 
287
291
  async verify(fix: boolean): Promise<VerifyResult> {
288
292
  // The one safe autofix this repo actually has. Anything more would be the gate rewriting
@@ -313,11 +317,21 @@ export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpSer
313
317
  // every code it could have quoted.
314
318
  await Promise.all([loadApp(input.root), loadCodeFixes()]);
315
319
  const lazy = lazyServices(input);
320
+ const ui = uiCapabilities({ root: input.root, env: input.env });
316
321
  const introspection = frameworkIntrospection({
317
322
  routes: () => describeRoutes(),
318
323
  policies: () => policyFacts(),
319
324
  });
320
- const server = createDevServer({ host: devHost(introspection, capabilities(input, lazy)) });
325
+ const server = createDevServer({ host: devHost(introspection, capabilities(input, lazy, ui)) });
321
326
  const caller = localCaller();
322
- return { server, caller, tools: server.tools.names(caller), close: () => lazy.close() };
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
+ };
323
337
  }
package/src/mcp-ui.ts CHANGED
@@ -10,14 +10,16 @@ import { join } from 'node:path';
10
10
  import { UltimateError } from '@ultimat3/core';
11
11
  import type { UiIslandInput, UiIslandResult, UiShotInput, UiShotResult } from '@ultimat3/mcp';
12
12
  import { describeRoutes } from '@ultimat3/render';
13
+ import type { ScrapeDriver } from '@ultimat3/scraping';
13
14
  import { DEFAULT_PAGE_TIMEOUT_MS } from '@ultimat3/scraping';
14
15
  import { appBrowser } from './browser-launcher';
15
16
  import { DEFAULT_SETTLE_MS, runShot, SHOT_DIR, shotSlug } from './cmd-shot';
16
17
  import { islandShot } from './cmd-shot-island';
17
18
  import type { Env } from './dev-services';
18
19
  import { islandVerdictJson } from './island-verdict';
20
+ import { retryMemo } from './retry-memo';
19
21
  import { shotBrowserChoice } from './shot-browser';
20
- import { devServerFor } from './shot-server';
22
+ import { devServerFor, type ShotServer } from './shot-server';
21
23
  import { verdictJson } from './shot-verdict';
22
24
 
23
25
  /** Kernel-picked, as `x shot` picks it: a scratch server never fights another project for :3000. */
@@ -65,28 +67,62 @@ export function matches(pattern: string, path: string): boolean {
65
67
  export interface UiHostInput {
66
68
  readonly root: string;
67
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;
68
76
  }
69
77
 
70
- export function uiCapabilities(input: UiHostInput): {
78
+ export interface UiCapabilities {
71
79
  shotRoute(shot: UiShotInput): Promise<UiShotResult>;
72
80
  shotIsland(island: UiIslandInput): Promise<UiIslandResult>;
73
- } {
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 {
74
86
  const { root, env } = input;
75
- const boot = () => devServerFor(root, env, SCRATCH_PORT);
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
+ };
76
99
  // The same choice `x shot` makes from the environment: `PUPPETEER_EXECUTABLE_PATH`, a
77
100
  // provider's CDP URL, or the launcher's own discovery.
78
101
  const browser = () => shotBrowserChoice({ cdpFlag: undefined, browserFlag: undefined, env });
102
+ const routes = input.routes ?? describeRoutes;
103
+ let closed = false;
79
104
 
80
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
+
81
114
  async shotRoute(shot) {
82
- assertBudgetedRoute(shot.route, describeRoutes());
115
+ assertBudgetedRoute(shot.route, routes());
83
116
  const { cdpUrl, executablePath } = browser();
84
- const driver = await appBrowser({
85
- root,
86
- viewport: shot.viewport,
87
- ...(executablePath === undefined ? {} : { executablePath }),
88
- ...(cdpUrl === undefined ? {} : { cdpUrl }),
89
- });
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);
90
126
  // One directory per (route, viewport, scheme), so two pictures of one route at two widths
91
127
  // never overwrite each other and an agent can hold both.
92
128
  const outDir = join(