@ultimat3/cli 20.1.2 → 20.1.3

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.3",
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.3",
41
+ "@ultimat3/admin": "20.1.3",
42
+ "@ultimat3/ai": "20.1.3",
43
+ "@ultimat3/auth": "20.1.3",
44
+ "@ultimat3/cache": "20.1.3",
45
+ "@ultimat3/core": "20.1.3",
46
+ "@ultimat3/db": "20.1.3",
47
+ "@ultimat3/entity": "20.1.3",
48
+ "@ultimat3/flags": "20.1.3",
49
+ "@ultimat3/http": "20.1.3",
50
+ "@ultimat3/i18n": "20.1.3",
51
+ "@ultimat3/jobs": "20.1.3",
52
+ "@ultimat3/mail": "20.1.3",
53
+ "@ultimat3/manifest": "20.1.3",
54
+ "@ultimat3/mcp": "20.1.3",
55
+ "@ultimat3/money": "20.1.3",
56
+ "@ultimat3/notify": "20.1.3",
57
+ "@ultimat3/policy": "20.1.3",
58
+ "@ultimat3/pwa": "20.1.3",
59
+ "@ultimat3/query": "20.1.3",
60
+ "@ultimat3/realtime": "20.1.3",
61
+ "@ultimat3/render": "20.1.3",
62
+ "@ultimat3/schema": "20.1.3",
63
+ "@ultimat3/scraping": "20.1.3",
64
+ "@ultimat3/seo": "20.1.3",
65
+ "@ultimat3/storage": "20.1.3",
66
+ "@ultimat3/testing": "20.1.3",
67
+ "@ultimat3/time": "20.1.3",
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-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
+ }
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-host.ts CHANGED
@@ -46,6 +46,7 @@ import { explainErrorCode } from './mcp-errors';
46
46
  import { parseBunTest } from './mcp-test-output';
47
47
  import { readMigrations } from './migrations';
48
48
  import { retryMemo } from './retry-memo';
49
+ import { testEnvOverrides } from './test-dotenv';
49
50
 
50
51
  export interface DevHostInput {
51
52
  readonly root: string;
@@ -178,7 +179,7 @@ export async function readOnlyRows(
178
179
  }
179
180
 
180
181
  function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
181
- const { root, runner } = input;
182
+ const { root, runner, env } = input;
182
183
  // Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
183
184
  // loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
184
185
  // resolution is not an answer: `??=` kept the rejection, so a statement timeout on the DDL
@@ -239,10 +240,14 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
239
240
 
240
241
  // `bun test <filter>` matches on the test path, the same rule `x test`'s `discoverTests` uses.
241
242
  async runTests(filter: string | undefined) {
243
+ // Same leak the CLI's own `x test`/`x verify` had: `.env.development` auto-loaded into
244
+ // THIS process must not ride along into the `bun test` child the dev MCP server spawns.
245
+ const envOverrides = testEnvOverrides(root, env);
242
246
  const result = await runner(
243
247
  filter === undefined ? ['bun', 'test'] : ['bun', 'test', filter],
244
248
  {
245
249
  cwd: root,
250
+ ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
246
251
  },
247
252
  );
248
253
  return parseBunTest(execOutput(result), result.durationMs);
@@ -21,6 +21,31 @@ export const HANDWRITTEN_ERRORS = `import { UltimateError } from '@ultimat3/core
21
21
  export class LedgerClosedError extends UltimateError {}
22
22
  `;
23
23
 
24
+ /**
25
+ * A feature slice whose entity is NOT tenant-scoped — the shape `x g entity`'s own comment
26
+ * describes for a single-tenant app, and never a shape the generator writes itself. The
27
+ * generator's INPUT only, the same way `HANDWRITTEN_ERRORS` above is: `x g job`/`x g task` read
28
+ * this from a real `entity.ts`, and a fixture that never exercised it compiled only the shape
29
+ * that never failed.
30
+ */
31
+ export const HANDWRITTEN_ENTITY_NO_TENANT = `import { entity, text, uuid } from '@ultimat3/entity';
32
+
33
+ export const shortLink = entity('short_links', {
34
+ columns: { id: uuid().primaryKey(), url: text({ max: 2000 }) },
35
+ });
36
+
37
+ export type ShortLink = typeof shortLink.$row;
38
+ `;
39
+
40
+ /** The paired `repo.ts`: no `byId`, no `listByOrg` — nothing this slice's job may call. */
41
+ export const HANDWRITTEN_REPO_NO_TENANT = `import { db, sql } from '@ultimat3/db';
42
+ import type { ShortLink } from './entity';
43
+
44
+ export async function list(limit = 50): Promise<readonly ShortLink[]> {
45
+ return db().query<ShortLink>(sql\`select * from short_links order by url limit \${limit}\`);
46
+ }
47
+ `;
48
+
24
49
  /**
25
50
  * One realistic invocation of every generator, on top of `x new --example`. Names differ from
26
51
  * their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
@@ -47,6 +72,23 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
47
72
  { kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
48
73
  { kind: 'backfill', name: 'reindex-invoices', feature: 'invoice' },
49
74
  { kind: 'task', name: 'nightly-sweep', feature: 'invoice' },
75
+ // The other shape both templates have: a feature whose entity names no tenant column, so the
76
+ // job/task must not assume one — compiled here beside the tenant-scoped pair above, exactly as
77
+ // `ping-invoice`/`touch-invoice` compile the action's other shape beside `send-invoice`.
78
+ {
79
+ kind: 'job',
80
+ name: 'purge-orphans',
81
+ feature: 'short-link',
82
+ sliceEntity: HANDWRITTEN_ENTITY_NO_TENANT,
83
+ sliceRepo: HANDWRITTEN_REPO_NO_TENANT,
84
+ },
85
+ {
86
+ kind: 'task',
87
+ name: 'nightly-purge',
88
+ feature: 'short-link',
89
+ sliceEntity: HANDWRITTEN_ENTITY_NO_TENANT,
90
+ sliceRepo: HANDWRITTEN_REPO_NO_TENANT,
91
+ },
50
92
  { kind: 'route', name: 'pricing', surface: 'site' },
51
93
  { kind: 'route', name: 'billing', surface: 'app' },
52
94
  // `--at`, pointed at the `site/` route above: an island's whole reason to exist is a 0kb page
@@ -12,7 +12,8 @@ export { entityFiles } from './entity';
12
12
  export { guardCode, guardFiles } from './guard';
13
13
  export type { IslandOptions } from './island';
14
14
  export { islandFiles } from './island';
15
- export { jobFiles, taskFiles } from './job';
15
+ export type { JobOptions } from './job';
16
+ export { isTenantScopedSlice, jobFiles, taskFiles } from './job';
16
17
  export { CATALOG_ROOT, catalogPath, DEFAULT_LOCALES, resolveLocales } from './locales';
17
18
  // All three members of the `GeneratedFile` union, not two: the barrel exported the union and the
18
19
  // foundation variant only, so a consumer could hold a `GeneratedFile` and had no name to narrow it
@@ -3,10 +3,11 @@
3
3
  // both; the generated test pins them through a real driver, because a key that is not stable is a
4
4
  // job that runs twice and a tenant that is not declared is a job that reads the wrong org's rows.
5
5
 
6
+ import { stripComments } from '../ts-scan';
6
7
  import type { FeatureTarget } from './entity';
7
8
  import type { GeneratedFile, NameSet } from './naming';
8
9
  import { names } from './naming';
9
- import { sliceFoundation } from './slice-foundation';
10
+ import { sliceExports, sliceFoundation } from './slice-foundation';
10
11
 
11
12
  const jobSource = (
12
13
  name: NameSet,
@@ -39,6 +40,42 @@ export const ${name.camel} = job({
39
40
  });
40
41
  `;
41
42
 
43
+ /**
44
+ * The other shape: this feature's own `entity.ts` names no tenant column (or names `'none'`), or
45
+ * its `repo.ts` does not export the `byId`/`listByOrg` pair the tenant-scoped body above calls —
46
+ * checked by `isTenantScopedSlice` against what is actually on the app's disk, never assumed. The
47
+ * body imports neither `../entity` nor `../repo`, so it compiles whether this feature has an
48
+ * entity yet or has one with no tenant, and `tenant: 'none'` is stated rather than defaulted so a
49
+ * reviewer sees the decision instead of an absence.
50
+ */
51
+ const neutralJobSource = (
52
+ name: NameSet,
53
+ ): string => `// ${name.camel}: multi-step durable work with no tenant behind it. Each step is retried
54
+ // independently and its result is stored under its name — step names are stable identifiers, not
55
+ // labels. \`t\` comes from @ultimat3/jobs, not @ultimat3/schema: a job file imports one package.
56
+
57
+ import { job, t } from '@ultimat3/jobs';
58
+
59
+ export const ${name.camel} = job({
60
+ input: t.object({ id: t.uuid }),
61
+ // This feature has no tenant column to derive an org from — either it has no entity yet, or its
62
+ // entity names none. \`tenant: 'none'\` STRIPS the org from the run rather than leaving one
63
+ // behind, so a tenant-scoped read added later fails closed with X_TENANCY_ACTOR_ORG_REQUIRED
64
+ // instead of reading whichever org the enqueuer happened to hold. Once this feature's entity
65
+ // declares \`tenant: 'orgId'\` and its repo exports \`byId\`/\`listByOrg\`, the next \`x g job\` in
66
+ // this slice scaffolds the tenant-scoped shape above instead.
67
+ tenant: 'none',
68
+ idempotencyKey: ({ id }) => \`${name.kebab}:\${id}\`,
69
+ retry: { attempts: 5, backoff: 'exponential' },
70
+ async run({ step }) {
71
+ await step.run('process', async () => {
72
+ // TODO: this job's own work.
73
+ });
74
+ return { processed: true };
75
+ },
76
+ });
77
+ `;
78
+
42
79
  const taskSource = (
43
80
  name: NameSet,
44
81
  jobName: NameSet,
@@ -65,6 +102,25 @@ export const ${name.camel} = task({
65
102
  });
66
103
  `;
67
104
 
105
+ /** The task's other shape: enqueues the neutral job above, so its payload carries no `orgId`. */
106
+ const neutralTaskSource = (
107
+ name: NameSet,
108
+ jobName: NameSet,
109
+ ): string => `// ${name.camel}: a scheduled trigger. Tasks only enqueue jobs — the work itself is durable and
110
+ // retryable, and the schedule carries an explicit IANA time zone.
111
+
112
+ import { task } from '@ultimat3/jobs';
113
+ import { ${jobName.camel} } from '../jobs/${jobName.kebab}';
114
+
115
+ export const ${name.camel} = task({
116
+ cron: '0 3 * * *',
117
+ tz: 'UTC',
118
+ // No org in the payload: the job this enqueues declares \`tenant: 'none'\`, because this
119
+ // feature's entity names no tenant column (or has none yet).
120
+ enqueue: () => [[${jobName.camel}, { id: '00000000-0000-4000-8000-000000000001' }]],
121
+ });
122
+ `;
123
+
68
124
  const jobTest = (
69
125
  name: NameSet,
70
126
  ): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
@@ -121,6 +177,63 @@ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
121
177
  });
122
178
  `;
123
179
 
180
+ /** The job test's other shape: no `orgId` anywhere, and a `tenantFor` that reads `undefined`. */
181
+ const neutralJobTest = (
182
+ name: NameSet,
183
+ ): string => `// ${name.camel} against a real driver: enqueue, drain, assert. Retries and the dead-letter path
184
+ // are the framework's, so what this pins is that THIS job's steps run and are idempotent.
185
+ import { createMemoryDriver, resetJobDriver, setJobDriver } from '@ultimat3/jobs';
186
+ import { afterAll, beforeAll, expect, jobTest } from '@ultimat3/testing';
187
+ import { ${name.camel} } from './${name.kebab}';
188
+
189
+ const id = '00000000-0000-4000-8000-000000000001';
190
+ const input = { id };
191
+ // The key this job owes, spelled once. Named rather than inlined so the assertion below carries
192
+ // the job's own name and still fits the formatter width the app's \`lint\` step enforces.
193
+ const expectedKey = \`${name.kebab}:\${id}\`;
194
+
195
+ // The driver is process-global, so it is installed and released around this file rather than
196
+ // left behind for whichever test happens to run next.
197
+ beforeAll(() => {
198
+ setJobDriver(createMemoryDriver());
199
+ });
200
+ afterAll(resetJobDriver);
201
+
202
+ jobTest('${name.camel} declares a key and a retry policy', () => {
203
+ expect(${name.camel}.kind).toBe('job');
204
+ expect(${name.camel}.idempotencyKeyFor(input)).toBe(expectedKey);
205
+ expect(${name.camel}.retry.attempts).toBeGreaterThan(1);
206
+ });
207
+
208
+ jobTest('${name.camel} derives the same key for the same input', () => {
209
+ const key = ${name.camel}.idempotencyKeyFor(input);
210
+ expect(${name.camel}.idempotencyKeyFor(input)).toBe(key);
211
+ });
212
+
213
+ jobTest('${name.camel} declares no tenant', () => {
214
+ // This feature's entity names no tenant column (or has none yet) — \`tenant: 'none'\` is the
215
+ // declaration for that, and it strips one rather than inheriting the worker's, which is what
216
+ // stands between a read added here later and X_TENANCY_ACTOR_ORG_REQUIRED, or worse, another
217
+ // org's rows if this ever gains one.
218
+ expect(${name.camel}.tenantFor(input)).toBeUndefined();
219
+ });
220
+
221
+ jobTest('${name.camel} projects itself into the manifest', () => {
222
+ const described = ${name.camel}.describe();
223
+ expect(described.queue).toBe('default');
224
+ expect(described.retry.attempts).toBe(5);
225
+ });
226
+
227
+ jobTest('${name.camel} enqueues once, and dedupes the retry', async () => {
228
+ // The whole point of the key: an at-least-once caller may enqueue twice and the work still
229
+ // happens once. \`.enqueue()\` is the one queue path — a job is never run inline.
230
+ const first = await ${name.camel}.enqueue(input);
231
+ expect(first.deduped).toBe(false);
232
+ const again = await ${name.camel}.enqueue(input);
233
+ expect(again.deduped).toBe(true);
234
+ });
235
+ `;
236
+
124
237
  const taskTest = (
125
238
  name: NameSet,
126
239
  jobName: NameSet,
@@ -163,28 +276,75 @@ jobTest('${name.camel} fires its declared entries', async () => {
163
276
  });
164
277
  `;
165
278
 
166
- export function jobFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
279
+ export interface JobOptions extends FeatureTarget {
280
+ /**
281
+ * This feature's own `entity.ts` as it stands on disk, or absent when the feature has none yet.
282
+ * Supplied by `run` for the same reason `ActionOptions.sliceErrors` is: whether the slice is
283
+ * tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
284
+ * `repo.byId`/`repo.listByOrg` calls into a feature whose repo never declared them —
285
+ * `x g task purgeOrphans --feature links` on a `links` slice with no `orgId` produced files that
286
+ * did not compile.
287
+ */
288
+ readonly sliceEntity?: string;
289
+ /** This feature's own `repo.ts` as it stands on disk, or absent alongside `sliceEntity`. */
290
+ readonly sliceRepo?: string;
291
+ }
292
+
293
+ /**
294
+ * Whether `x g job`/`x g task` may assume the tenant-scoped shape: an `entity.ts` this feature
295
+ * does not have yet is about to be scaffolded fresh by `sliceFoundation` below, tenant-scoped by
296
+ * default — so absent counts as scoped. One that exists is trusted over that default: it declares
297
+ * a real, non-`'none'` `tenant`, AND its `repo.ts` actually exports the `byId`/`listByOrg` pair the
298
+ * tenant-scoped body calls. Both have to hold — an entity that still names `tenant: 'orgId'` after
299
+ * an author trimmed `listByOrg` out of `repo.ts` (or never generated one) is not a slice this job
300
+ * can read through either.
301
+ */
302
+ export function isTenantScopedSlice(
303
+ sliceEntity: string | undefined,
304
+ sliceRepo: string | undefined,
305
+ ): boolean {
306
+ if (sliceEntity === undefined) return true;
307
+ const declaresTenant = /\btenant\s*:\s*'(?!none')[^']+'/.test(stripComments(sliceEntity));
308
+ if (!declaresTenant) return false;
309
+ if (sliceRepo === undefined) return true;
310
+ return sliceExports(sliceRepo, 'byId') && sliceExports(sliceRepo, 'listByOrg');
311
+ }
312
+
313
+ export function jobFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
167
314
  const name = names(rawName);
168
315
  const dir = `${target.surfaceDir}/${target.feature}/jobs`;
316
+ const scoped = isTenantScopedSlice(target.sliceEntity, target.sliceRepo);
169
317
  return [
170
318
  // The job's steps read through `../repo`, which carries `../entity` for its row type. No
171
319
  // policy: a job has no request behind it and evaluates none, so a generated one would be a
172
320
  // file nobody asked for. `x g task` inherits this by composing `jobFiles` below.
173
- ...sliceFoundation(target, ['entity']),
174
- { path: `${dir}/${name.kebab}.ts`, contents: jobSource(name) },
321
+ // Only for the tenant-scoped shape: the neutral job below reads neither module, and a slice
322
+ // this feature does not own the tenancy of is not this generator's to scaffold an entity into.
323
+ ...(scoped ? sliceFoundation(target, ['entity']) : []),
324
+ {
325
+ path: `${dir}/${name.kebab}.ts`,
326
+ contents: scoped ? jobSource(name) : neutralJobSource(name),
327
+ },
175
328
  // `.job.test.ts`, because the gate types a test by its FILENAME: a `jobTest` in a plain
176
329
  // `<name>.test.ts` runs under `unit`, and `x test job` answers X_TEST_NO_FILES in an app that
177
330
  // is full of them. Same lesson `x g route` already carries for `page.e2e.test.ts`.
178
- { path: `${dir}/${name.kebab}.job.test.ts`, contents: jobTest(name) },
331
+ {
332
+ path: `${dir}/${name.kebab}.job.test.ts`,
333
+ contents: scoped ? jobTest(name) : neutralJobTest(name),
334
+ },
179
335
  ];
180
336
  }
181
337
 
182
- export function taskFiles(rawName: string, target: FeatureTarget): readonly GeneratedFile[] {
338
+ export function taskFiles(rawName: string, target: JobOptions): readonly GeneratedFile[] {
183
339
  const name = names(rawName);
184
340
  const jobName = names(`${rawName}-job`);
185
341
  const dir = `${target.surfaceDir}/${target.feature}/tasks`;
342
+ const scoped = isTenantScopedSlice(target.sliceEntity, target.sliceRepo);
186
343
  return [
187
- { path: `${dir}/${name.kebab}.ts`, contents: taskSource(name, jobName) },
344
+ {
345
+ path: `${dir}/${name.kebab}.ts`,
346
+ contents: scoped ? taskSource(name, jobName) : neutralTaskSource(name, jobName),
347
+ },
188
348
  // A task's test is a `jobTest` too — it drives a queue — so it takes the same suffix.
189
349
  { path: `${dir}/${name.kebab}.job.test.ts`, contents: taskTest(name, jobName) },
190
350
  ...jobFiles(`${rawName}-job`, target),
@@ -23,9 +23,13 @@ const devActor = (
23
23
  // \`X_CONFIG_INVALID\` — which is what a scaffolded app did on its very first \`x dev\`.
24
24
  //
25
25
  // DEVELOPMENT ONLY, and the guard is the point: a viewer that followed this to staging would sign
26
- // every visitor in as an admin. \`bun test\` sets \`NODE_ENV=test\`, so it does not install there
27
- // either — a fixture mints its own actor, and a second one arriving from a cookie would decide
28
- // which actor a test is about.
26
+ // every visitor in as an admin. FAILS CLOSED (\`fallback: 'production'\`) rather than trusting
27
+ // \`tryResolveEnvironment\`'s own default: that default is \`development\`, so a process that named
28
+ // NEITHER \`ULTIMATE_ENV\` nor \`NODE_ENV\` would otherwise read as development too — indistinguishable
29
+ // from the one this file exists to allow. \`x dev\` declares \`ULTIMATE_ENV=development\` for exactly
30
+ // this reason (whenever neither key is already set), so a bare \`x dev\` still installs this viewer;
31
+ // \`bun test\` sets \`NODE_ENV=test\`, so it does not install there either — a fixture mints its own
32
+ // actor, and a second one arriving from a cookie would decide which actor a test is about.
29
33
  //
30
34
  // REPLACE IT with the real thing: resolve a session cookie to a row, and return that actor.
31
35
  // Everything downstream — pages, policies, live subscribers, MCP tools — reads what this returns.
@@ -78,7 +82,7 @@ export const devActorFor = (role: DevRole): Actor => ({
78
82
  export function installDevAuthenticator(
79
83
  env: Readonly<Record<string, string | undefined>> = process.env,
80
84
  ): boolean {
81
- if (tryResolveEnvironment({ env }) !== 'development') return false;
85
+ if (tryResolveEnvironment({ env, fallback: 'production' }) !== 'development') return false;
82
86
  configureAuthenticator((request) => devActorFor(devRoleFrom(request.header('cookie'))));
83
87
  logger.warn('every request is answered as a development viewer', {
84
88
  role: DEFAULT_DEV_ROLE,
@@ -133,6 +137,11 @@ unitTest('it installs in development and in no other environment', () => {
133
137
 
134
138
  expect(installDevAuthenticator({ ULTIMATE_ENV: 'production' })).toBe(false);
135
139
  expect(installDevAuthenticator({ ULTIMATE_ENV: 'staging' })).toBe(false);
140
+ // FAILS CLOSED: a process naming NEITHER key is production here, never the default-development
141
+ // a bare \`tryResolveEnvironment({ env })\` would answer. \`x dev\` is what makes a real \`x dev\`
142
+ // still install this viewer — it declares \`ULTIMATE_ENV=development\` before this module loads,
143
+ // for exactly the process this call simulates having none of.
144
+ expect(installDevAuthenticator({})).toBe(false);
136
145
  expect(configuredAuthenticator()).toBeUndefined();
137
146
 
138
147
  expect(installDevAuthenticator({ ULTIMATE_ENV: 'development' })).toBe(true);
@@ -115,8 +115,12 @@ const listedExports = (code: string): readonly string[] =>
115
115
  */
116
116
  export function sliceExports(source: string, name: string): boolean {
117
117
  const code = stripComments(source);
118
+ // `(?:async\s+)?` before `function`: an `export async function byId` — every repo function
119
+ // `x g entity` scaffolds — matched neither this nor `listedExports`, so a caller checking for
120
+ // `byId`/`listByOrg` on a real repo.ts always read `false`. `async` has no meaning before
121
+ // `class`/`const`/`let`/`var`/`enum`, so it is scoped to `function` only.
118
122
  const declared = new RegExp(
119
- `\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|function|enum)\\s+${name}\\b`,
123
+ `\\bexport\\s+(?:abstract\\s+)?(?:class|const|let|var|(?:async\\s+)?function|enum)\\s+${name}\\b`,
120
124
  );
121
125
  return declared.test(code) || listedExports(code).includes(name);
122
126
  }
@@ -0,0 +1,125 @@
1
+ // Single responsibility: which env keys reached THIS `x` process only because Bun auto-loaded
2
+ // `.env.development` / `.env.development.local` at startup — so a `bun test` child this process
3
+ // spawns (`test-shards.ts`, `verify-tests.ts`, `verify-test-run.ts`, `mcp-host.ts`'s `runTests`)
4
+ // does not inherit a key a bare `bun test` would never have seen.
5
+ //
6
+ // PROBED ON BUN 1.4.2, and the probe decided the rule: an ambient env var SHADOWS a dotenv file's
7
+ // own value for the same key (`FOO=dev bun test` with a fixture `.env.test` declaring `FOO=test`
8
+ // still read `FOO=dev` inside the test). So a leaked key is never made safe by a legitimate test
9
+ // file (`.env`, `.env.test`, …) also declaring it — if we left it in place, the leaked value would
10
+ // go on shadowing that file's own value exactly as it shadows `.env.test` above. The only question
11
+ // worth asking is the one `devOnlyLeakedKeys` answers: does this process's CURRENT value match
12
+ // what `.env.development`/`.env.development.local` would have set? If yes, delete it and let the
13
+ // child's own dotenv load (or absence of one) answer instead. If the current value differs, this
14
+ // process's env holds something dotenv did not put there — a real export, CI, `.env.local` — and
15
+ // deleting it is not this function's call to make.
16
+ //
17
+ // KNOWN LIMITATION, stated rather than hidden: a real ambient value that happens to COINCIDE with
18
+ // the dev file's value is indistinguishable from a leak from here — there is no pre-dotenv
19
+ // snapshot to compare against. This function resolves that ambiguity toward closing the leak.
20
+ //
21
+ // A SECOND PARSER, not a call to `env-example.ts`'s `parseEnvKeys`: that function is deliberately
22
+ // keys-only ("half of them are placeholders" — it feeds `.env.example` rendering, where a secret's
23
+ // value must never appear), and widening it to return values would change what a template
24
+ // generator ships. This one exists to COMPARE values, a different job with a different file.
25
+
26
+ import { readFileSync } from 'node:fs'; // why: Bun ships no synchronous file read with a graceful-missing return.
27
+ // why: Bun exposes no path-join primitive.
28
+ import { join } from 'node:path';
29
+
30
+ /** Bun's own grammar, matched against `env-example.ts`'s `ENV_KEY_RE` (kept separate: see header). */
31
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
32
+
33
+ /**
34
+ * One dotenv file's key/value pairs. A `Map`, not an object literal: a key of `constructor` or
35
+ * `__proto__` passes `ENV_KEY_RE` and reads back as a function on `Object.prototype`, the exact
36
+ * defect this repo's `verify-tests.ts` header names thirteen instances of.
37
+ *
38
+ * Quoting: a value opening with `"` or `'` runs to its closing quote (or end of value if the
39
+ * dotenv file never closes it), `#` included — Bun does not stop a quoted value at an internal
40
+ * `#`. An unquoted value stops at the first `#`, which is where an inline comment starts.
41
+ */
42
+ export function parseDotenvValues(text: string): ReadonlyMap<string, string> {
43
+ const values = new Map<string, string>();
44
+ for (const raw of text.split('\n')) {
45
+ const line = raw.trim().replace(/^export\s+/, '');
46
+ if (line === '' || line.startsWith('#')) continue;
47
+ const separator = line.indexOf('=');
48
+ if (separator <= 0) continue;
49
+ const key = line.slice(0, separator).trim();
50
+ if (!ENV_KEY_RE.test(key)) continue;
51
+ const rest = line.slice(separator + 1).trim();
52
+ const quote = rest.startsWith('"') || rest.startsWith("'") ? rest[0] : undefined;
53
+ if (quote === undefined) {
54
+ const hash = rest.indexOf('#');
55
+ values.set(key, (hash >= 0 ? rest.slice(0, hash) : rest).trim());
56
+ continue;
57
+ }
58
+ const closing = rest.indexOf(quote, 1);
59
+ values.set(key, closing >= 0 ? rest.slice(1, closing) : rest.slice(1));
60
+ }
61
+ return values;
62
+ }
63
+
64
+ export interface DevOnlyLeakInput {
65
+ /** `.env.development`'s text, or `''` when the file does not exist. */
66
+ readonly devText: string;
67
+ /** `.env.development.local`'s text, or `''` — wins over `devText` for a shared key, Bun's own precedence. */
68
+ readonly devLocalText: string;
69
+ /** This process's environment, as `exec.ts` would spawn a child with it (before any override). */
70
+ readonly env: Readonly<Record<string, string | undefined>>;
71
+ }
72
+
73
+ /**
74
+ * The pure decision, no filesystem: every key `.env.development`/`.env.development.local` would
75
+ * set, where `env`'s CURRENT value for that key is exactly what the file would have set. See this
76
+ * file's header for why a legitimate test file declaring the same key does not exempt it.
77
+ */
78
+ export function devOnlyLeakedKeys(input: DevOnlyLeakInput): readonly string[] {
79
+ const merged = new Map(parseDotenvValues(input.devText));
80
+ for (const [key, value] of parseDotenvValues(input.devLocalText)) merged.set(key, value);
81
+ const leaked: string[] = [];
82
+ for (const [key, devValue] of merged) {
83
+ // `Object.hasOwn` FIRST: `key` is data (a name out of a dotenv file), and `env['constructor']`
84
+ // on a plain object answers `Object.prototype`'s member, not `undefined` — the rule
85
+ // `scripts/proto-index.ts` ratchets, with its own sanctioned repair.
86
+ if (Object.hasOwn(input.env, key) && input.env[key] === devValue) leaked.push(key);
87
+ }
88
+ return leaked;
89
+ }
90
+
91
+ /** A missing dotenv file is the common case (no `.env.development.local` in most checkouts). */
92
+ function readIfPresent(path: string): string {
93
+ try {
94
+ return readFileSync(path, 'utf8');
95
+ } catch {
96
+ return '';
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The env override a `bun test` child spawned FROM `root` should get: every dev-only leaked key
102
+ * deleted. `exec.ts`'s `ExecOptions.env` reads an `undefined` value as "unset for this child,
103
+ * even though the parent has it" — see that file for why the merge could not otherwise express
104
+ * a deletion.
105
+ *
106
+ * `root` is the app/repo root the caller already resolved — NOT necessarily `process.cwd()`, which
107
+ * is what Bun actually auto-loaded `.env.development` relative to at this process's own startup.
108
+ * The two agree for every command that boots from the repo/app root, which is every one of them
109
+ * today; a future command invoked from a subdirectory would fail SAFE here (the file this reads
110
+ * would differ from the one Bun loaded, values would not match, and nothing gets stripped) rather
111
+ * than stripping the wrong key.
112
+ */
113
+ export function testEnvOverrides(
114
+ root: string,
115
+ env: Readonly<Record<string, string | undefined>>,
116
+ ): Readonly<Record<string, string | undefined>> {
117
+ const leaked = devOnlyLeakedKeys({
118
+ devText: readIfPresent(join(root, '.env.development')),
119
+ devLocalText: readIfPresent(join(root, '.env.development.local')),
120
+ env,
121
+ });
122
+ const overrides: Record<string, string | undefined> = {};
123
+ for (const key of leaked) overrides[key] = undefined;
124
+ return overrides;
125
+ }
@@ -40,6 +40,7 @@ import { execOutput } from './exec';
40
40
  import { msg } from './messages';
41
41
  import type { CommandResult, Finding, JsonValue, StepResult } from './output';
42
42
  import { quoteArg } from './shell-quote';
43
+ import { testEnvOverrides } from './test-dotenv';
43
44
  import { testPasses } from './test-passes';
44
45
  import type { TestFile } from './test-select';
45
46
  import type { TestType } from './verify-tests';
@@ -165,6 +166,13 @@ export interface RunShardsOptions {
165
166
  readonly affected?: AffectedSelection;
166
167
  /** Everything after the caller's `--`, forwarded to every pass and printed in the reproduce. */
167
168
  readonly passthrough?: readonly string[];
169
+ /**
170
+ * This process's own environment, as `exec.ts` would otherwise hand it whole to the spawned
171
+ * child. Optional and defaulted to `Bun.env`: every real caller's is `Bun.env` already (`x
172
+ * test`/`x verify` read `CommandContext.env`, itself `Bun.env`), so the default is not a
173
+ * fallback so much as a seam this file's own tests use to hand it a fixture instead.
174
+ */
175
+ readonly env?: Readonly<Record<string, string | undefined>>;
168
176
  }
169
177
 
170
178
  /**
@@ -219,6 +227,11 @@ export const failureOf = (code: number, files: number, plan: ReproduceOptions):
219
227
  */
220
228
  export async function runShards(options: RunShardsOptions): Promise<CommandResult> {
221
229
  const only = options.only;
230
+ // Computed ONCE per invocation, never per pass: every pass spawns from the same `root` and the
231
+ // same parent env, so the leaked-key set cannot differ pass to pass.
232
+ const envOverrides: Record<string, string | undefined> = {
233
+ ...testEnvOverrides(options.root, options.env ?? Bun.env),
234
+ };
222
235
  const passes = testPasses({
223
236
  files: options.files,
224
237
  workers: options.workers,
@@ -241,7 +254,14 @@ export async function runShards(options: RunShardsOptions): Promise<CommandResul
241
254
  }),
242
255
  {
243
256
  cwd: options.root,
244
- ...(only === undefined ? {} : { env: { ULTIMATE_TEST_WORKER: String(only) } }),
257
+ ...(Object.keys(envOverrides).length === 0 && only === undefined
258
+ ? {}
259
+ : {
260
+ env: {
261
+ ...envOverrides,
262
+ ...(only === undefined ? {} : { ULTIMATE_TEST_WORKER: String(only) }),
263
+ },
264
+ }),
245
265
  },
246
266
  );
247
267
  const plan = planOf(options, pass);
@@ -65,6 +65,13 @@ export type HostCheck = (root: string) => Promise<readonly Finding[]>;
65
65
  export interface VerifyContext {
66
66
  readonly root: string;
67
67
  readonly runner: Runner;
68
+ /**
69
+ * This process's own environment. Optional and defaulted to `Bun.env` at every reader
70
+ * (`verify-tests.ts`'s `runSerial`/`runType`): every real caller's IS `Bun.env` already
71
+ * (`cmd-verify.ts` passes `ctx.env`, itself `Bun.env`), so the default only matters to a test
72
+ * that constructs a `VerifyContext` fixture and never mentions `env`.
73
+ */
74
+ readonly env?: Readonly<Record<string, string | undefined>>;
68
75
  readonly hostChecks?: Partial<Record<VerifyStepName, HostCheck>>;
69
76
  /**
70
77
  * How wide the parallel test steps go. Absent means `defaultWorkers()` — a knob, never a
@@ -6,6 +6,7 @@ import type { Runner } from './exec';
6
6
  import { execOutput } from './exec';
7
7
  import type { Finding } from './output';
8
8
  import { countsOf } from './test-counts';
9
+ import { testEnvOverrides } from './test-dotenv';
9
10
  import type { TestFile } from './test-select';
10
11
  import { failureOf, testArgs } from './test-shards';
11
12
  import type { StepOutcome } from './verify-step';
@@ -20,6 +21,8 @@ export interface ParallelRunOptions {
20
21
  readonly workers: number;
21
22
  /** Carried into the `fix:` so a failure reproduces as `x test <type> --workers N`. */
22
23
  readonly type: TestType;
24
+ /** This process's own environment. Optional and defaulted to `Bun.env` — see `VerifyContext.env`. */
25
+ readonly env?: Readonly<Record<string, string | undefined>>;
23
26
  }
24
27
 
25
28
  /**
@@ -38,7 +41,11 @@ export interface ParallelRunOptions {
38
41
  export async function runParallel(options: ParallelRunOptions): Promise<StepOutcome> {
39
42
  const files = options.files.map((file) => file.path);
40
43
  const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
41
- const result = await options.runner(testArgs({ files, workers }), { cwd: options.root });
44
+ const envOverrides = testEnvOverrides(options.root, options.env ?? Bun.env);
45
+ const result = await options.runner(testArgs({ files, workers }), {
46
+ cwd: options.root,
47
+ ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
48
+ });
42
49
  // `failureOf` is `x test`'s own, imported rather than restated: the two paths report the SAME
43
50
  // failed `bun test`, so a second literal here is two `cause:` strings and two `fix:` lines free
44
51
  // to drift — and the one that drifts is the gate's, which is the one an agent reads first.
@@ -15,6 +15,7 @@ import { TEST_TYPES } from '@ultimat3/testing';
15
15
  import { checkEvalBaselines, checkEvalCoverage, checkEvalRecording } from './app-evals';
16
16
  import { APP_CONFIG_FILE } from './app-root';
17
17
  import { countsOf } from './test-counts';
18
+ import { testEnvOverrides } from './test-dotenv';
18
19
  import type { TestFile } from './test-select';
19
20
  import { discoverTests } from './test-select';
20
21
  import { defaultWorkers } from './test-workers';
@@ -201,7 +202,11 @@ export const resetTestDiscovery = (): void => discovered.clear();
201
202
 
202
203
  const runSerial = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome> => {
203
204
  const command = testStepCommand(type);
204
- const result = await ctx.runner(command, { cwd: ctx.root });
205
+ const envOverrides = testEnvOverrides(ctx.root, ctx.env ?? Bun.env);
206
+ const result = await ctx.runner(command, {
207
+ cwd: ctx.root,
208
+ ...(Object.keys(envOverrides).length === 0 ? {} : { env: envOverrides }),
209
+ });
205
210
  return {
206
211
  ...fromExec(result, {
207
212
  code: 'X_TEST_FAILED',
@@ -223,6 +228,7 @@ const runType = async (ctx: VerifyContext, type: TestType): Promise<StepOutcome>
223
228
  files,
224
229
  workers: ctx.workers ?? defaultWorkers(),
225
230
  type,
231
+ ...(ctx.env === undefined ? {} : { env: ctx.env }),
226
232
  });
227
233
  };
228
234