@ultimat3/cli 20.1.2 → 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 +29 -29
- package/src/browser-launcher.ts +11 -4
- package/src/cdp-launch.ts +14 -5
- package/src/cmd-dev.ts +4 -0
- package/src/cmd-generate.ts +24 -1
- package/src/cmd-mcp.ts +2 -2
- package/src/cmd-shot.ts +8 -1
- package/src/cmd-test.ts +1 -0
- package/src/cmd-verify.ts +1 -0
- package/src/dev-environment.ts +42 -0
- package/src/error-codes.ts +5 -0
- package/src/exec.ts +33 -2
- package/src/generate-files.ts +24 -2
- package/src/island-capture.ts +1 -0
- package/src/island-harness-script.ts +54 -5
- package/src/island-verdict.ts +11 -0
- package/src/mcp-errors.ts +3 -0
- package/src/mcp-host.ts +9 -1
- package/src/mcp-ui.ts +136 -0
- package/src/output.ts +1 -1
- package/src/scaffold-fixture.ts +42 -0
- package/src/templates/guard-unzoned-date.ts +18 -1
- package/src/templates/index.ts +2 -1
- package/src/templates/job.ts +167 -7
- package/src/templates/scaffold-auth.ts +13 -4
- package/src/templates/slice-foundation.ts +5 -1
- package/src/test-dotenv.ts +125 -0
- package/src/test-shards.ts +21 -1
- package/src/verify-step.ts +7 -0
- package/src/verify-test-run.ts +8 -1
- package/src/verify-tests.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "20.1.
|
|
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.
|
|
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.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/browser-launcher.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync } from 'node:fs';
|
|
|
13
13
|
import { UltimateError } from '@ultimat3/core';
|
|
14
14
|
import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
|
|
15
15
|
import { localBrowser, remoteBrowser } from '@ultimat3/scraping';
|
|
16
|
-
import { CHROME_CANDIDATES } from './cdp-launch';
|
|
16
|
+
import { CHROME_CANDIDATES, CONTAINER_CHROME_ARGS } from './cdp-launch';
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* The one library this works against. Playwright is not an alternative and is not a flag:
|
|
@@ -231,8 +231,15 @@ export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriv
|
|
|
231
231
|
...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
|
|
232
232
|
// `LocalBrowserOptions.options` is passed through to `launch()` untouched, which is the seam
|
|
233
233
|
// that lets the CLI size a browser without `@ultimat3/scraping` naming a puppeteer type.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
//
|
|
235
|
+
// `args` carries `CONTAINER_CHROME_ARGS` on EVERY local launch, viewport or not — the same
|
|
236
|
+
// `--no-sandbox` / `--disable-dev-shm-usage` `cdp-launch.ts`'s e2e driver already needed for
|
|
237
|
+
// this container, read from the one export rather than restated. Before this, `x shot` was the
|
|
238
|
+
// only browser-launching command in the tree with neither, so a box where the e2e gate ran
|
|
239
|
+
// green could not run `x shot` at all — Chrome exits "No usable sandbox".
|
|
240
|
+
options: {
|
|
241
|
+
args: [...CONTAINER_CHROME_ARGS],
|
|
242
|
+
...(options.viewport === undefined ? {} : { defaultViewport: { ...options.viewport } }),
|
|
243
|
+
},
|
|
237
244
|
});
|
|
238
245
|
}
|
package/src/cdp-launch.ts
CHANGED
|
@@ -36,6 +36,19 @@ export async function findChrome(
|
|
|
36
36
|
return undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The two flags a CONTAINER needs, regardless of which process launches Chrome: the sandbox needs
|
|
41
|
+
* privileges CI (and an Ubuntu 23.10+ host with AppArmor's unprivileged-user-namespace restriction
|
|
42
|
+
* — Chrome exits "No usable sandbox" there with neither) does not grant, and `/dev/shm` is 64 MB in
|
|
43
|
+
* a default container, which crashes the renderer on any real page.
|
|
44
|
+
*
|
|
45
|
+
* Exported so `browser-launcher.ts`'s `appBrowser` — a DIFFERENT launch path, `puppeteer-core`'s
|
|
46
|
+
* own `launch()` rather than the `Bun.spawn` below — passes the SAME two, rather than a second
|
|
47
|
+
* list that agrees today and drifts the next time either changes. `x shot` had neither before this
|
|
48
|
+
* export existed, so a box where `x verify`'s e2e gate ran green could not run `x shot` at all.
|
|
49
|
+
*/
|
|
50
|
+
export const CONTAINER_CHROME_ARGS: readonly string[] = ['--no-sandbox', '--disable-dev-shm-usage'];
|
|
51
|
+
|
|
39
52
|
/**
|
|
40
53
|
* The flags, and every one of them earns its line.
|
|
41
54
|
*
|
|
@@ -43,16 +56,12 @@ export async function findChrome(
|
|
|
43
56
|
* asks the OS for a free port, so two suites on one machine never collide — the port is read back
|
|
44
57
|
* off stderr, which is the only place Chrome states the one it took. A throwaway `--user-data-dir`
|
|
45
58
|
* because a run sharing a profile with a real browser inherits its cookies and locks its files.
|
|
46
|
-
* `--no-sandbox` and `--disable-dev-shm-usage` are the two a container needs: the sandbox needs
|
|
47
|
-
* privileges CI does not grant, and `/dev/shm` is 64 MB in a default container, which crashes the
|
|
48
|
-
* renderer on any real page.
|
|
49
59
|
*/
|
|
50
60
|
const flags = (profileDir: string): readonly string[] => [
|
|
51
61
|
'--headless=new',
|
|
52
62
|
'--remote-debugging-port=0',
|
|
53
63
|
`--user-data-dir=${profileDir}`,
|
|
54
|
-
|
|
55
|
-
'--disable-dev-shm-usage',
|
|
64
|
+
...CONTAINER_CHROME_ARGS,
|
|
56
65
|
'--disable-gpu',
|
|
57
66
|
// Nothing here should reach the network on its own account, and a first-run bubble or an update
|
|
58
67
|
// check is a page load the test did not ask for.
|
package/src/cmd-dev.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { CliCommand, CommandContext } from './command';
|
|
|
24
24
|
import { assetRoutes } from './dev-assets';
|
|
25
25
|
import type { DevDashboardInput, DevStatus } from './dev-dashboard';
|
|
26
26
|
import { devDashboardRoutes, devPanels } from './dev-dashboard';
|
|
27
|
+
import { declareDevEnvironment } from './dev-environment';
|
|
27
28
|
import { liveFeedLabel } from './dev-live-feed';
|
|
28
29
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
29
30
|
import { createStatementLedger } from './dev-n-plus-one';
|
|
@@ -374,6 +375,9 @@ export const devCommand: CliCommand = {
|
|
|
374
375
|
},
|
|
375
376
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
376
377
|
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);
|
|
377
381
|
// Validated, not `parseInt`'d: `x dev --port abc` handed `NaN` to `Bun.serve`, which binds an
|
|
378
382
|
// arbitrary port — a dev server reachable at an address nothing printed.
|
|
379
383
|
const port = intFlagOr(
|
package/src/cmd-generate.ts
CHANGED
|
@@ -72,12 +72,19 @@ export const generateCommand: CliCommand = {
|
|
|
72
72
|
// imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
|
|
73
73
|
const catalogModule = await resolveCatalogModule(root);
|
|
74
74
|
// Read for the same reason: which errors the slice declares is written on THIS app's disk.
|
|
75
|
-
const
|
|
75
|
+
const slice = sliceDir(surface, featureFlag ?? name);
|
|
76
|
+
const sliceErrors = await readSliceErrors(root, kind, slice);
|
|
77
|
+
// Same reason again: whether `job`/`task` may assume the tenant-scoped shape is a fact about
|
|
78
|
+
// THIS feature's own `entity.ts`/`repo.ts`, not a default the template gets to assume.
|
|
79
|
+
const sliceEntity = await readSliceFile(root, kind, slice, 'entity.ts');
|
|
80
|
+
const sliceRepo = await readSliceFile(root, kind, slice, 'repo.ts');
|
|
76
81
|
const files = generate({
|
|
77
82
|
kind,
|
|
78
83
|
name,
|
|
79
84
|
...(featureFlag === undefined ? {} : { feature: featureFlag }),
|
|
80
85
|
...(sliceErrors === undefined ? {} : { sliceErrors }),
|
|
86
|
+
...(sliceEntity === undefined ? {} : { sliceEntity }),
|
|
87
|
+
...(sliceRepo === undefined ? {} : { sliceRepo }),
|
|
81
88
|
...(at === undefined ? {} : { at }),
|
|
82
89
|
...(permission === undefined ? {} : { permission }),
|
|
83
90
|
surface,
|
|
@@ -157,3 +164,19 @@ async function readSliceErrors(
|
|
|
157
164
|
const file = containedPath(root, `${slice}/errors.ts`);
|
|
158
165
|
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
159
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* `job` and `task` only: the slice's `entity.ts`/`repo.ts` as they stand on disk, absent when the
|
|
170
|
+
* generator's kind is neither or the file does not exist yet. `readSliceErrors`'s reason —
|
|
171
|
+
* whichever generator reads it decides on THIS app's disk, not on a default the template assumes.
|
|
172
|
+
*/
|
|
173
|
+
async function readSliceFile(
|
|
174
|
+
root: string,
|
|
175
|
+
kind: Generator,
|
|
176
|
+
slice: string,
|
|
177
|
+
name: 'entity.ts' | 'repo.ts',
|
|
178
|
+
): Promise<string | undefined> {
|
|
179
|
+
if (kind !== 'job' && kind !== 'task') return undefined;
|
|
180
|
+
const file = containedPath(root, `${slice}/${name}`);
|
|
181
|
+
return existsSync(file) ? await Bun.file(file).text() : undefined;
|
|
182
|
+
}
|
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/cmd-test.ts
CHANGED
package/src/cmd-verify.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Single responsibility: whether `x dev` must declare `ULTIMATE_ENV` for the app it is about to
|
|
2
|
+
// boot, and the one-liner side effect that declares it. Split out of `cmd-dev.ts` (which the
|
|
3
|
+
// filesize gate holds to ~500 lines) rather than folded into it — this is one decision with one
|
|
4
|
+
// consumer, and keeping it separate is what lets a test pin the decision without paying for a
|
|
5
|
+
// whole app boot.
|
|
6
|
+
|
|
7
|
+
import { ENVIRONMENT_KEY } from '@ultimat3/core';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether `x dev` must declare `ULTIMATE_ENV` for the app it is about to boot: true only when
|
|
11
|
+
* NEITHER key `@ultimat3/core`'s `resolveEnvironment` reads is set to a real value. Empty-string
|
|
12
|
+
* matches that reader's own rule (`packages/core/src/environment.ts`'s `readEnvironment`):
|
|
13
|
+
* `ULTIMATE_ENV=''` is treated as unset, so "already set" here means non-empty, exactly as there.
|
|
14
|
+
*
|
|
15
|
+
* `NODE_ENV=ci` (or any other non-`Environment` value) counts as "already set" and is left alone
|
|
16
|
+
* even though `resolveEnvironment` would still fall through it to `DEFAULT_ENVIRONMENT` — an
|
|
17
|
+
* operator who set SOMETHING gets no override from this process, only a process that named
|
|
18
|
+
* NEITHER key does.
|
|
19
|
+
*/
|
|
20
|
+
export function needsDevEnvironmentDeclaration(
|
|
21
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
22
|
+
): boolean {
|
|
23
|
+
const declared = env[ENVIRONMENT_KEY];
|
|
24
|
+
const nodeEnv = env['NODE_ENV'];
|
|
25
|
+
return (declared === undefined || declared === '') && (nodeEnv === undefined || nodeEnv === '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The one-liner side effect, split from the decision so a test can pin either without paying for
|
|
30
|
+
* a whole app boot: `process.env[ENVIRONMENT_KEY] = 'development'`, and only when
|
|
31
|
+
* `needsDevEnvironmentDeclaration` says neither key was set.
|
|
32
|
+
*
|
|
33
|
+
* Mutates the real `process.env`, not a copy: `cmd-dev.ts`'s `run` passes `ctx.env`, which IS
|
|
34
|
+
* `Bun.env` (probed on 1.4.2 — `Bun.env === process.env`), and it is `process.env` that
|
|
35
|
+
* `resolveEnvironment`'s default reader (no explicit `env` passed) and every app module loaded
|
|
36
|
+
* in-process actually consult. The call has to land before `startDev` imports a single app
|
|
37
|
+
* module — see `cmd-dev.ts`'s `run` for why that means the top of the command, not inside
|
|
38
|
+
* `startDev` itself, which stays a pure function of the `env` it is handed.
|
|
39
|
+
*/
|
|
40
|
+
export function declareDevEnvironment(env: Readonly<Record<string, string | undefined>>): void {
|
|
41
|
+
if (needsDevEnvironmentDeclaration(env)) process.env[ENVIRONMENT_KEY] = 'development';
|
|
42
|
+
}
|
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/exec.ts
CHANGED
|
@@ -21,7 +21,16 @@ export interface ExecResult {
|
|
|
21
21
|
|
|
22
22
|
export interface ExecOptions {
|
|
23
23
|
readonly cwd: string;
|
|
24
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Overlaid onto `Bun.env`, key by key: a string sets/overrides it for the child, and
|
|
26
|
+
* `undefined` UNSETS it — the child does not inherit it at all, even though the parent has it.
|
|
27
|
+
* The delete case exists for one caller (`test-dotenv.ts`'s `testEnvOverrides`, spent by
|
|
28
|
+
* `test-shards.ts`/`verify-tests.ts`/`verify-test-run.ts`/`mcp-host.ts`): a `bun test` child
|
|
29
|
+
* must not inherit a key that reached the parent only through Bun auto-loading
|
|
30
|
+
* `.env.development`. A merge that only ever adds or overrides (`{ ...Bun.env, ...env }`) cannot
|
|
31
|
+
* express that — `Bun.env` is always the base, so a key just absent from `env` survives from it.
|
|
32
|
+
*/
|
|
33
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
25
34
|
readonly stdin?: string;
|
|
26
35
|
}
|
|
27
36
|
|
|
@@ -48,12 +57,34 @@ const now = (): number => performance.now();
|
|
|
48
57
|
*
|
|
49
58
|
* The return type is inferred so this stays one statement of `Bun.spawn`'s own shape.
|
|
50
59
|
*/
|
|
60
|
+
/**
|
|
61
|
+
* `Bun.env` overlaid with `overrides`, an `undefined` value deleting the key rather than setting
|
|
62
|
+
* it to the string `"undefined"` — see `ExecOptions.env`'s own comment for why a delete has to be
|
|
63
|
+
* expressible here at all.
|
|
64
|
+
*/
|
|
65
|
+
function mergedEnv(
|
|
66
|
+
overrides: Readonly<Record<string, string | undefined>>,
|
|
67
|
+
): Record<string, string> {
|
|
68
|
+
// A `Map`, not an object indexed by `key`: the keys are DATA (environment variable names), and
|
|
69
|
+
// a plain-object table read or deleted by a computed key is the `Object.prototype` hazard
|
|
70
|
+
// `scripts/proto-index.ts` ratchets. `Object.fromEntries` builds the record once, at the end.
|
|
71
|
+
const merged = new Map<string, string>();
|
|
72
|
+
for (const [key, value] of Object.entries(Bun.env)) {
|
|
73
|
+
if (value !== undefined) merged.set(key, value);
|
|
74
|
+
}
|
|
75
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
76
|
+
if (value === undefined) merged.delete(key);
|
|
77
|
+
else merged.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
return Object.fromEntries(merged);
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
function spawnOrRefuse(command: readonly string[], options: ExecOptions) {
|
|
52
83
|
const [head = '', ...rest] = command;
|
|
53
84
|
try {
|
|
54
85
|
return Bun.spawn([head, ...rest], {
|
|
55
86
|
cwd: options.cwd,
|
|
56
|
-
env: options.env === undefined ? Bun.env :
|
|
87
|
+
env: options.env === undefined ? Bun.env : mergedEnv(options.env),
|
|
57
88
|
stdin: options.stdin === undefined ? 'ignore' : new TextEncoder().encode(options.stdin),
|
|
58
89
|
stdout: 'pipe',
|
|
59
90
|
stderr: 'pipe',
|
package/src/generate-files.ts
CHANGED
|
@@ -55,6 +55,16 @@ export interface GenerateOptions {
|
|
|
55
55
|
* import of a class the app never declared. Read at `sliceDir(surface, feature)/errors.ts`.
|
|
56
56
|
*/
|
|
57
57
|
readonly sliceErrors?: string;
|
|
58
|
+
/**
|
|
59
|
+
* `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when the feature has no
|
|
60
|
+
* entity yet. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
|
|
61
|
+
* tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
|
|
62
|
+
* `repo.byId`/`repo.listByOrg` calls into a feature whose entity names no tenant column. Read at
|
|
63
|
+
* `sliceDir(surface, feature)/entity.ts`.
|
|
64
|
+
*/
|
|
65
|
+
readonly sliceEntity?: string;
|
|
66
|
+
/** `job` and `task`: the slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
|
|
67
|
+
readonly sliceRepo?: string;
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
@@ -110,9 +120,21 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
110
120
|
case 'query':
|
|
111
121
|
return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
|
|
112
122
|
case 'job':
|
|
113
|
-
return dedupe(
|
|
123
|
+
return dedupe(
|
|
124
|
+
jobFiles(options.name, {
|
|
125
|
+
...target,
|
|
126
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
127
|
+
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
114
130
|
case 'task':
|
|
115
|
-
return dedupe(
|
|
131
|
+
return dedupe(
|
|
132
|
+
taskFiles(options.name, {
|
|
133
|
+
...target,
|
|
134
|
+
...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
|
|
135
|
+
...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
116
138
|
case 'island':
|
|
117
139
|
return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
|
|
118
140
|
// No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
|
package/src/island-capture.ts
CHANGED
|
@@ -267,6 +267,7 @@ export async function captureIslandState(
|
|
|
267
267
|
box: seen?.box ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
268
268
|
mounted: seen?.mounted === true,
|
|
269
269
|
unstubbed: seen?.unstubbed ?? [],
|
|
270
|
+
sockets: seen?.sockets ?? [],
|
|
270
271
|
console: page.console(),
|
|
271
272
|
pageErrors: page.pageErrors(),
|
|
272
273
|
overflow: seen?.overflow ?? { x: false, y: false },
|
|
@@ -65,10 +65,54 @@ var method=(init&&init.method)||(typeof input==='object'&&input&&input.method)||
|
|
|
65
65
|
var path=pathOf(url);var k=method.toUpperCase()+' '+path;
|
|
66
66
|
var respond=stubFor(method,path);bump();
|
|
67
67
|
return answer(respond,k).then(function(r){bump();return r},function(e){bump();throw e})};
|
|
68
|
-
// A socket
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
// A socket or an event stream needs no stub, because a SNAPSHOT of an island does not need a
|
|
69
|
+
// LIVE one: every state's fixture already rides \`props\`/\`routes\`, so the component's own
|
|
70
|
+
// realtime layer has nothing to tell it that the fetch/XHR seal does not already say. Refusing
|
|
71
|
+
// the construction outright — as this did until the defect that made every live island
|
|
72
|
+
// unphotographable — fails a component for opening a channel whose data this harness was never
|
|
73
|
+
// asked to carry, in EVERY state, because \`mount()\` dials it unconditionally.
|
|
74
|
+
//
|
|
75
|
+
// So the stand-in is INERT rather than refused: no real dial, no network, no message and no error
|
|
76
|
+
// ever delivered. \`close()\`/\`send()\` are no-ops. Recorded on \`W.sockets\`, never on
|
|
77
|
+
// \`W.unstubbed\` — a real unanswered fetch still fails the run (the refusal above is unchanged),
|
|
78
|
+
// but a socket a component merely opened and heard nothing from is not the same fact as a request
|
|
79
|
+
// nobody stubbed, and \`stateShotOk\` must not conflate the two; neither field is hidden from the
|
|
80
|
+
// verdict, both ride \`--json\` plainly, so this is not a candidate for \`ISLAND_BLIND_SPOTS\` —
|
|
81
|
+
// that list is for a fact a PICTURE cannot show, and a socket's inertness is a fact this JSON
|
|
82
|
+
// already states.
|
|
83
|
+
//
|
|
84
|
+
// \`readyState\` DOES leave CONNECTING, on a zero-delay timer, once: a component whose \`mount()\`
|
|
85
|
+
// awaits the socket's own \`open\` before it renders anything — never true of the reference app's
|
|
86
|
+
// \`LiveClient\`, whose \`connect()\` registers callbacks and returns, but not a fact this harness
|
|
87
|
+
// may assume of every app — would otherwise hang the mount forever, trading one impossible
|
|
88
|
+
// \`X_SHOT_ISLAND_UNSTUBBED_REQUEST\` fix for an unreachable \`--settle\` deadline, the same defect
|
|
89
|
+
// under a different name. So \`onopen\`/an \`'open'\` listener fires exactly once; \`onmessage\` and
|
|
90
|
+
// \`onerror\` never do, because this is a channel that opened and then heard nothing, not one that
|
|
91
|
+
// received data no fixture could have supplied.
|
|
92
|
+
function inertSocket(kind,url){
|
|
93
|
+
W.sockets.push(kind+' '+url);
|
|
94
|
+
var listeners={};
|
|
95
|
+
var self={
|
|
96
|
+
readyState:0,url:String(url),
|
|
97
|
+
addEventListener:function(type,fn){(listeners[type]=listeners[type]||[]).push(fn)},
|
|
98
|
+
removeEventListener:function(type,fn){var l=listeners[type];if(!l)return;
|
|
99
|
+
var i=l.indexOf(fn);if(i>=0)l.splice(i,1)},
|
|
100
|
+
dispatchEvent:function(){return true},
|
|
101
|
+
send:function(){},
|
|
102
|
+
close:function(){},
|
|
103
|
+
onopen:null,onmessage:null,onerror:null,onclose:null};
|
|
104
|
+
setTimeout(function(){
|
|
105
|
+
self.readyState=1;
|
|
106
|
+
var ev={type:'open',target:self};
|
|
107
|
+
if(typeof self.onopen==='function')self.onopen(ev);
|
|
108
|
+
var handlers=listeners['open'];
|
|
109
|
+
if(handlers)for(var i=0;i<handlers.length;i+=1)handlers[i](ev)},0);
|
|
110
|
+
return self}
|
|
111
|
+
window.WebSocket=function(url){return inertSocket('WS',url)};
|
|
112
|
+
window.WebSocket.CONNECTING=0;window.WebSocket.OPEN=1;
|
|
113
|
+
window.WebSocket.CLOSING=2;window.WebSocket.CLOSED=3;
|
|
114
|
+
window.EventSource=function(url){return inertSocket('SSE',url)};
|
|
115
|
+
window.EventSource.CONNECTING=0;window.EventSource.OPEN=1;window.EventSource.CLOSED=2;
|
|
72
116
|
var RealXHR=window.XMLHttpRequest;
|
|
73
117
|
window.XMLHttpRequest=function(){var xhr=new RealXHR();var open=xhr.open;
|
|
74
118
|
xhr.open=function(method,url){W.unstubbed.push(String(method).toUpperCase()+' '+pathOf(url));
|
|
@@ -117,7 +161,7 @@ requestAnimationFrame(tick)}
|
|
|
117
161
|
/** The whole prelude, in the one order that works: state, seal, clock, then the readiness watch. */
|
|
118
162
|
export function harnessScript(options: HarnessScriptOptions): string {
|
|
119
163
|
return [
|
|
120
|
-
`window.${HARNESS_GLOBAL}={harness:true,activity:0,ready:false,unstubbed:[]};`,
|
|
164
|
+
`window.${HARNESS_GLOBAL}={harness:true,activity:0,ready:false,unstubbed:[],sockets:[]};`,
|
|
121
165
|
sealScript(options.stubs),
|
|
122
166
|
clockScript(options.now, options.timeZone),
|
|
123
167
|
readyScript(),
|
|
@@ -138,6 +182,11 @@ export const readinessProbe = (selector: string): string =>
|
|
|
138
182
|
'var r=box?box.getBoundingClientRect():{width:0,height:0,x:0,y:0};' +
|
|
139
183
|
'return{harness:W.harness===true,ready:W.ready===true,' +
|
|
140
184
|
'unstubbed:(W.unstubbed||[]).slice(),' +
|
|
185
|
+
// Recorded beside `unstubbed`, never merged into it: a socket a component opened and never
|
|
186
|
+
// heard back from is not the same fact as a request nobody stubbed, and `stateShotOk` reads
|
|
187
|
+
// neither — a component may legitimately hold an open, silent channel in a state that is
|
|
188
|
+
// otherwise clean.
|
|
189
|
+
'sockets:(W.sockets||[]).slice(),' +
|
|
141
190
|
'attached:host!==null&&document.body.contains(host),' +
|
|
142
191
|
'mounted:host!==null&&host.hasAttribute("data-x-mounted"),' +
|
|
143
192
|
'failed:host&&host.hasAttribute("data-x-failed")?host.getAttribute("data-x-failed"):null,' +
|
package/src/island-verdict.ts
CHANGED
|
@@ -54,6 +54,13 @@ export interface IslandReadiness {
|
|
|
54
54
|
readonly harness: boolean;
|
|
55
55
|
readonly ready: boolean;
|
|
56
56
|
readonly unstubbed: readonly string[];
|
|
57
|
+
/**
|
|
58
|
+
* `"WS <url>"` / `"SSE <url>"` for every socket a component constructed — recorded, never
|
|
59
|
+
* gating. The harness's stand-in is inert (constructs, never opens, `close()` is a no-op), so
|
|
60
|
+
* a component dialing `@ultimat3/realtime`'s `LiveClient.connect()` does not fail the state it
|
|
61
|
+
* is mounted in; this is the fact a picture cannot carry about that.
|
|
62
|
+
*/
|
|
63
|
+
readonly sockets: readonly string[];
|
|
57
64
|
readonly attached: boolean;
|
|
58
65
|
readonly mounted: boolean;
|
|
59
66
|
readonly failed: string | null;
|
|
@@ -86,6 +93,7 @@ const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
|
|
|
86
93
|
harness: t.boolean,
|
|
87
94
|
ready: t.boolean,
|
|
88
95
|
unstubbed: t.array(t.string),
|
|
96
|
+
sockets: t.array(t.string),
|
|
89
97
|
attached: t.boolean,
|
|
90
98
|
mounted: t.boolean,
|
|
91
99
|
failed: t.nullable(t.string),
|
|
@@ -115,6 +123,8 @@ export interface IslandStateShot {
|
|
|
115
123
|
readonly box: IslandBox;
|
|
116
124
|
readonly mounted: boolean;
|
|
117
125
|
readonly unstubbed: readonly string[];
|
|
126
|
+
/** See `IslandReadiness.sockets` — recorded, and read by neither `stateShotOk` nor the gate. */
|
|
127
|
+
readonly sockets: readonly string[];
|
|
118
128
|
readonly console: readonly ConsoleLine[];
|
|
119
129
|
readonly pageErrors: readonly PageError[];
|
|
120
130
|
/**
|
|
@@ -184,6 +194,7 @@ const shotJson = (shot: IslandStateShot): JsonValue => ({
|
|
|
184
194
|
warnings: stateShotWarnings(shot).length,
|
|
185
195
|
overflow: { x: shot.overflow.x, y: shot.overflow.y },
|
|
186
196
|
unstubbed: [...shot.unstubbed],
|
|
197
|
+
sockets: [...shot.sockets],
|
|
187
198
|
console: shot.console.map((line) => ({ level: line.level, text: line.text, at: line.at })),
|
|
188
199
|
pageErrors: shot.pageErrors.map((error) => ({
|
|
189
200
|
message: error.message,
|
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,8 +44,10 @@ 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';
|
|
50
|
+
import { testEnvOverrides } from './test-dotenv';
|
|
49
51
|
|
|
50
52
|
export interface DevHostInput {
|
|
51
53
|
readonly root: string;
|
|
@@ -178,7 +180,7 @@ export async function readOnlyRows(
|
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
|
|
181
|
-
const { root, runner } = input;
|
|
183
|
+
const { root, runner, env } = input;
|
|
182
184
|
// Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
|
|
183
185
|
// loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
|
|
184
186
|
// resolution is not an answer: `??=` kept the rejection, so a statement timeout on the DDL
|
|
@@ -239,10 +241,14 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
239
241
|
|
|
240
242
|
// `bun test <filter>` matches on the test path, the same rule `x test`'s `discoverTests` uses.
|
|
241
243
|
async runTests(filter: string | undefined) {
|
|
244
|
+
// Same leak the CLI's own `x test`/`x verify` had: `.env.development` auto-loaded into
|
|
245
|
+
// THIS process must not ride along into the `bun test` child the dev MCP server spawns.
|
|
246
|
+
const envOverrides = testEnvOverrides(root, env);
|
|
242
247
|
const result = await runner(
|
|
243
248
|
filter === undefined ? ['bun', 'test'] : ['bun', 'test', filter],
|
|
244
249
|
{
|
|
245
250
|
cwd: root,
|
|
251
|
+
...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
|
|
246
252
|
},
|
|
247
253
|
);
|
|
248
254
|
return parseBunTest(execOutput(result), result.durationMs);
|
|
@@ -276,6 +282,8 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
|
|
|
276
282
|
|
|
277
283
|
explainError: explainErrorCode,
|
|
278
284
|
|
|
285
|
+
...uiCapabilities({ root, env }),
|
|
286
|
+
|
|
279
287
|
async verify(fix: boolean): Promise<VerifyResult> {
|
|
280
288
|
// The one safe autofix this repo actually has. Anything more would be the gate rewriting
|
|
281
289
|
// code it was asked to judge.
|