@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "20.1.2",
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.2",
41
- "@ultimat3/admin": "20.1.2",
42
- "@ultimat3/ai": "20.1.2",
43
- "@ultimat3/auth": "20.1.2",
44
- "@ultimat3/cache": "20.1.2",
45
- "@ultimat3/core": "20.1.2",
46
- "@ultimat3/db": "20.1.2",
47
- "@ultimat3/entity": "20.1.2",
48
- "@ultimat3/flags": "20.1.2",
49
- "@ultimat3/http": "20.1.2",
50
- "@ultimat3/i18n": "20.1.2",
51
- "@ultimat3/jobs": "20.1.2",
52
- "@ultimat3/mail": "20.1.2",
53
- "@ultimat3/manifest": "20.1.2",
54
- "@ultimat3/mcp": "20.1.2",
55
- "@ultimat3/money": "20.1.2",
56
- "@ultimat3/notify": "20.1.2",
57
- "@ultimat3/policy": "20.1.2",
58
- "@ultimat3/pwa": "20.1.2",
59
- "@ultimat3/query": "20.1.2",
60
- "@ultimat3/realtime": "20.1.2",
61
- "@ultimat3/render": "20.1.2",
62
- "@ultimat3/schema": "20.1.2",
63
- "@ultimat3/scraping": "20.1.2",
64
- "@ultimat3/seo": "20.1.2",
65
- "@ultimat3/storage": "20.1.2",
66
- "@ultimat3/testing": "20.1.2",
67
- "@ultimat3/time": "20.1.2",
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
  }
@@ -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
- ...(options.viewport === undefined
235
- ? {}
236
- : { options: { defaultViewport: { ...options.viewport } } }),
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
- '--no-sandbox',
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(
@@ -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 sliceErrors = await readSliceErrors(root, kind, sliceDir(surface, featureFlag ?? name));
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 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
package/src/cmd-test.ts CHANGED
@@ -197,6 +197,7 @@ export const testCommand: CliCommand = {
197
197
  const result = await runShards({
198
198
  root: ctx.cwd,
199
199
  runner: ctx.runner,
200
+ env: ctx.env,
200
201
  files,
201
202
  workers,
202
203
  ...(only === undefined ? {} : { only }),
package/src/cmd-verify.ts CHANGED
@@ -59,6 +59,7 @@ export const verifyCommand: CliCommand = {
59
59
  return runVerify(VERIFY_STEPS, {
60
60
  root,
61
61
  runner: ctx.runner,
62
+ env: ctx.env,
62
63
  ...(workers === undefined ? {} : { workers }),
63
64
  ...(only === undefined ? {} : { only }),
64
65
  });
@@ -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
+ }
@@ -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
- readonly env?: Readonly<Record<string, string>>;
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 : { ...Bun.env, ...options.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',
@@ -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(jobFiles(options.name, target));
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(taskFiles(options.name, target));
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
@@ -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 and an event stream have no stub vocabulary at all, so both are refused outright and
69
- // recorded: a live component that opened one would otherwise sit in its loading branch forever.
70
- window.WebSocket=function(url){W.unstubbed.push('WS '+url);throw refuse('WS '+url)};
71
- window.EventSource=function(url){W.unstubbed.push('SSE '+url);throw refuse('SSE '+url)};
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,' +
@@ -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.