@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 +29 -29
- package/src/cmd-dev.ts +3 -3
- package/src/dev-sync.ts +14 -4
- package/src/mcp-host.ts +19 -5
- package/src/mcp-ui.ts +47 -11
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/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/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(
|
|
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
|
-
...
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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,
|
|
115
|
+
assertBudgetedRoute(shot.route, routes());
|
|
83
116
|
const { cdpUrl, executablePath } = browser();
|
|
84
|
-
const driver =
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|