@ultimat3/cli 22.10.0 → 22.12.0

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": "22.10.0",
3
+ "version": "22.12.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.10.0",
42
- "@ultimat3/admin": "22.10.0",
43
- "@ultimat3/ai": "22.10.0",
44
- "@ultimat3/auth": "22.10.0",
45
- "@ultimat3/cache": "22.10.0",
46
- "@ultimat3/core": "22.10.0",
47
- "@ultimat3/db": "22.10.0",
48
- "@ultimat3/entity": "22.10.0",
49
- "@ultimat3/flags": "22.10.0",
50
- "@ultimat3/http": "22.10.0",
51
- "@ultimat3/i18n": "22.10.0",
52
- "@ultimat3/jobs": "22.10.0",
53
- "@ultimat3/mail": "22.10.0",
54
- "@ultimat3/manifest": "22.10.0",
55
- "@ultimat3/mcp": "22.10.0",
56
- "@ultimat3/money": "22.10.0",
57
- "@ultimat3/notify": "22.10.0",
58
- "@ultimat3/policy": "22.10.0",
59
- "@ultimat3/pwa": "22.10.0",
60
- "@ultimat3/query": "22.10.0",
61
- "@ultimat3/realtime": "22.10.0",
62
- "@ultimat3/render": "22.10.0",
63
- "@ultimat3/schema": "22.10.0",
64
- "@ultimat3/seo": "22.10.0",
65
- "@ultimat3/storage": "22.10.0",
66
- "@ultimat3/testing": "22.10.0",
67
- "@ultimat3/time": "22.10.0",
41
+ "@ultimat3/action": "22.12.0",
42
+ "@ultimat3/admin": "22.12.0",
43
+ "@ultimat3/ai": "22.12.0",
44
+ "@ultimat3/auth": "22.12.0",
45
+ "@ultimat3/cache": "22.12.0",
46
+ "@ultimat3/core": "22.12.0",
47
+ "@ultimat3/db": "22.12.0",
48
+ "@ultimat3/entity": "22.12.0",
49
+ "@ultimat3/flags": "22.12.0",
50
+ "@ultimat3/http": "22.12.0",
51
+ "@ultimat3/i18n": "22.12.0",
52
+ "@ultimat3/jobs": "22.12.0",
53
+ "@ultimat3/mail": "22.12.0",
54
+ "@ultimat3/manifest": "22.12.0",
55
+ "@ultimat3/mcp": "22.12.0",
56
+ "@ultimat3/money": "22.12.0",
57
+ "@ultimat3/notify": "22.12.0",
58
+ "@ultimat3/policy": "22.12.0",
59
+ "@ultimat3/pwa": "22.12.0",
60
+ "@ultimat3/query": "22.12.0",
61
+ "@ultimat3/realtime": "22.12.0",
62
+ "@ultimat3/render": "22.12.0",
63
+ "@ultimat3/schema": "22.12.0",
64
+ "@ultimat3/seo": "22.12.0",
65
+ "@ultimat3/storage": "22.12.0",
66
+ "@ultimat3/testing": "22.12.0",
67
+ "@ultimat3/time": "22.12.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
@@ -16,7 +16,9 @@
16
16
  // A module that DEFINES a primitive is PINNED and never leaves the registry: its exports are held
17
17
  // by every importer and by a registry that refuses a second definition of one name (an entity is
18
18
  // `X_ENTITY_DUPLICATE`; a second action instance is a handler nobody routes to). The closure stops
19
- // at a pinned module, and an edit to one still needs a restart — exactly what it needed before.
19
+ // at a pinned module — and RECORDS it (`takeStalePins`): a pinned module above a change still holds
20
+ // the old instance of what changed (a slice's service under its query, an admin page's view under
21
+ // `defineAdmin`), so only a new process serves the save. `x dev` restarts on it (`dev-supervisor.ts`).
20
22
 
21
23
  // why: Bun ships no path API; import specifiers resolve against the importing file's directory.
22
24
  import { dirname, resolve } from 'node:path';
@@ -37,6 +39,27 @@ const nodes = new Map<string, Node>();
37
39
  /** Reverse edges: file → the files that import it. */
38
40
  const importers = new Map<string, Set<string>>();
39
41
  const pinned = new Set<string>();
42
+
43
+ /** A pinned module that still holds the old instance of `changed` — or IS `changed`. */
44
+ export interface StalePin {
45
+ /** The saved file, absolute. */
46
+ readonly changed: string;
47
+ /** The module that defines a primitive and was not re-evaluated, absolute. */
48
+ readonly pinned: string;
49
+ }
50
+
51
+ /** What the last rescans' closures stopped at, until `takeStalePins` reads it. */
52
+ let stale: StalePin[] = [];
53
+
54
+ /**
55
+ * Every pinned module a rescan since the last call could not refresh, sorted by pinned path, and
56
+ * forgets them. Empty: everything the saves reached was re-evaluated and the process serves it.
57
+ */
58
+ export function takeStalePins(): readonly StalePin[] {
59
+ const taken = stale;
60
+ stale = [];
61
+ return taken;
62
+ }
40
63
  /**
41
64
  * Edges cost a transpile scan and a resolve per import (~300 ms over notificado.co's 1200 modules),
42
65
  * so only the one process that rescans pays it: `x dev` turns this on before its first scan. Off,
@@ -54,6 +77,7 @@ export function resetReloadGraph(): void {
54
77
  nodes.clear();
55
78
  importers.clear();
56
79
  pinned.clear();
80
+ stale = [];
57
81
  }
58
82
 
59
83
  /** Marks a module that defines a primitive: never evicted, and the closure stops at it. */
@@ -185,18 +209,36 @@ async function changedFiles(root: string): Promise<string[]> {
185
209
  return changed;
186
210
  }
187
211
 
188
- /** `changed` and everything above it, stopping at — and excluding — every pinned module. */
212
+ /**
213
+ * `changed` and everything above it, stopping at — and excluding — every pinned module. Each
214
+ * pinned module a walk stopped at is recorded against the change that reached it, once.
215
+ */
189
216
  function closure(changed: readonly string[]): Set<string> {
190
217
  const dirty = new Set<string>();
191
- const stack = changed.filter((path) => !pinned.has(path));
192
- while (stack.length > 0) {
193
- const path = stack.pop();
194
- if (path === undefined || dirty.has(path)) continue;
195
- dirty.add(path);
196
- for (const importer of importers.get(path) ?? []) {
197
- if (!pinned.has(importer)) stack.push(importer);
218
+ const reached = new Map<string, string>();
219
+ for (const origin of changed) {
220
+ if (pinned.has(origin)) {
221
+ if (!reached.has(origin)) reached.set(origin, origin);
222
+ continue;
223
+ }
224
+ const seen = new Set<string>();
225
+ const stack = [origin];
226
+ while (stack.length > 0) {
227
+ const path = stack.pop();
228
+ if (path === undefined || seen.has(path)) continue;
229
+ seen.add(path);
230
+ dirty.add(path);
231
+ for (const importer of importers.get(path) ?? []) {
232
+ if (!pinned.has(importer)) stack.push(importer);
233
+ else if (!reached.has(importer)) reached.set(importer, origin);
234
+ }
198
235
  }
199
236
  }
237
+ const held = new Set(stale.map((entry) => entry.pinned));
238
+ const found = [...reached]
239
+ .filter(([module]) => !held.has(module))
240
+ .map(([module, origin]) => ({ changed: origin, pinned: module }));
241
+ stale = [...stale, ...found].sort((a, b) => (a.pinned < b.pinned ? -1 : 1));
200
242
  return dirty;
201
243
  }
202
244
 
package/src/bin.ts CHANGED
@@ -33,6 +33,20 @@ if (local !== undefined) {
33
33
  process.exit(await child.exited);
34
34
  }
35
35
 
36
+ // `x dev` supervises a child that serves (`dev-supervisor.ts`): a save that reaches a module which
37
+ // defines a primitive is a restart, since no re-import in one process can serve it. After the
38
+ // handoff above, so the supervisor and its children are the app's own CLI.
39
+ // Imported only for `dev`: every other command's process must not load, or register, anything more.
40
+ if (Bun.argv[2] === 'dev') {
41
+ const { devSupervision, freeDevPort, superviseDev } = await import('./dev-supervisor');
42
+ const supervision = devSupervision(Bun.argv.slice(2), Bun.env, freeDevPort);
43
+ if (supervision !== undefined) {
44
+ process.exit(
45
+ await superviseDev({ bin: import.meta.path, argv: supervision.argv, env: Bun.env }),
46
+ );
47
+ }
48
+ }
49
+
36
50
  const code = await dispatch({
37
51
  argv: Bun.argv.slice(2),
38
52
  cwd: process.cwd(),
@@ -152,6 +152,18 @@ export interface ShotSessionInit {
152
152
  * the locale asked for, never of whatever language the machine's Chrome happens to speak.
153
153
  */
154
154
  readonly headers?: Readonly<Record<string, string>> | undefined;
155
+ /**
156
+ * Set in the browser before the first navigation, each for `url`'s origin — `x shot --cookie`, so
157
+ * a page is photographed with its consent (or any other cookie-held choice) already decided.
158
+ */
159
+ readonly cookies?: readonly ShotCookie[] | undefined;
160
+ }
161
+
162
+ /** One cookie for a shot session: `url` scopes it to the app under test, never another host. */
163
+ export interface ShotCookie {
164
+ readonly name: string;
165
+ readonly value: string;
166
+ readonly url: string;
155
167
  }
156
168
 
157
169
  export interface ShotSession {
@@ -139,6 +139,15 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
139
139
  if (init.headers !== undefined && Object.keys(init.headers).length > 0) {
140
140
  await on('Network.setExtraHTTPHeaders', { headers: init.headers });
141
141
  }
142
+ // `Network.setCookie` needs the Network domain on and precedes `goto` for the header's
143
+ // reason: a consent banner decided after the first paint is a picture of the banner.
144
+ for (const cookie of init.cookies ?? []) {
145
+ await on('Network.setCookie', {
146
+ name: cookie.name,
147
+ value: cookie.value,
148
+ url: cookie.url,
149
+ });
150
+ }
142
151
  const size = init.viewport ?? viewport;
143
152
  await on('Emulation.setDeviceMetricsOverride', {
144
153
  width: size.width,
@@ -146,7 +155,11 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
146
155
  deviceScaleFactor: 1,
147
156
  mobile: false,
148
157
  });
149
- return { page: cdpShotPage({ connection, sessionId, init, watch }), close };
158
+ // A classic scrollbar takes ~15px of the layout, so a page "at 1440" was laid out at 1425 and
159
+ // a full-page capture came back 1425 wide. Hidden, the page is laid out at the width asked
160
+ // for, which is what a phone's overlay scrollbar and the reviewer's expectation both are.
161
+ await on('Emulation.setScrollbarsHidden', { hidden: true });
162
+ return { page: cdpShotPage({ connection, sessionId, init, watch, viewport: size }), close };
150
163
  } catch (error) {
151
164
  await close();
152
165
  throw error;
@@ -42,6 +42,8 @@ export interface CdpShotPageInput {
42
42
  readonly sessionId: string;
43
43
  readonly init: ShotSessionInit;
44
44
  readonly watch: PageWatch;
45
+ /** What the page is laid out in: a full-page capture is never narrower than it. */
46
+ readonly viewport?: { readonly width: number; readonly height: number } | undefined;
45
47
  }
46
48
 
47
49
  export function cdpShotPage(input: CdpShotPageInput): ShotPage {
@@ -164,7 +166,7 @@ export function cdpShotPage(input: CdpShotPageInput): ShotPage {
164
166
  ),
165
167
  query,
166
168
  evaluate,
167
- screenshot: (options?: ShotCapture) => capture(send, options ?? {}),
169
+ screenshot: (options?: ShotCapture) => capture(send, options ?? {}, input.viewport?.width ?? 0),
168
170
  async colorScheme(scheme: ShotColorScheme) {
169
171
  // An EMPTY list is CDP's reset; an explicit `no-preference` would be an override.
170
172
  const features =
@@ -191,7 +193,7 @@ type Send = (
191
193
  * PNG bytes of the viewport, the whole document, or one clip — never both of the last two, which
192
194
  * CDP would resolve silently in favour of one. A clip is CSS pixels in page coordinates.
193
195
  */
194
- async function capture(send: Send, framing: ShotCapture): Promise<Uint8Array> {
196
+ async function capture(send: Send, framing: ShotCapture, minWidth: number): Promise<Uint8Array> {
195
197
  const clip = framing.clip;
196
198
  assert(
197
199
  !(framing.fullPage === true && clip !== undefined),
@@ -212,7 +214,14 @@ async function capture(send: Send, framing: ShotCapture): Promise<Uint8Array> {
212
214
  const width = metrics?.['width'];
213
215
  const height = metrics?.['height'];
214
216
  if (typeof width === 'number' && typeof height === 'number') {
215
- region = { x: 0, y: 0, width: Math.ceil(width), height: Math.ceil(height) };
217
+ // Floored at the viewport: the content size is the LAYOUT width, which a scrollbar (or a
218
+ // document narrower than the window) makes smaller than the picture that was asked for.
219
+ region = {
220
+ x: 0,
221
+ y: 0,
222
+ width: Math.max(minWidth, Math.ceil(width)),
223
+ height: Math.ceil(height),
224
+ };
216
225
  }
217
226
  }
218
227
  const answer = await send('Page.captureScreenshot', {
package/src/cmd-dev.ts CHANGED
@@ -16,7 +16,8 @@ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
16
16
  import { createIsrController } from '@ultimat3/render/server';
17
17
  import { loadSignInPath } from './app-auth';
18
18
  import { appManifest } from './app-manifest';
19
- import { enableReloadTracking } from './app-reload-graph';
19
+ import type { StalePin } from './app-reload-graph';
20
+ import { enableReloadTracking, takeStalePins } from './app-reload-graph';
20
21
  import { requireAppRoot } from './app-root';
21
22
  import { loadAppRuntime } from './app-runtime';
22
23
  import { devSpec } from './cmd-dev-spec';
@@ -29,6 +30,7 @@ import { createStatementLedger } from './dev-n-plus-one';
29
30
  import { devPortFor } from './dev-port';
30
31
  import { coalesceReloads } from './dev-reload';
31
32
  import { devRouteTable } from './dev-route-table';
33
+ import { childRestart, restartFinding } from './dev-supervisor';
32
34
  import { createTraceRecorder } from './dev-traces';
33
35
  import { watchTree } from './dev-watch-tree';
34
36
  import { holdUntilShutdown } from './hold';
@@ -108,6 +110,12 @@ export interface StartDevOptions {
108
110
  readonly env: Readonly<Record<string, string | undefined>>;
109
111
  readonly roles?: readonly Role[];
110
112
  readonly onReload?: (file: string, durationMs: number) => void;
113
+ /**
114
+ * A save reached a module that defines a primitive, so this process cannot serve it
115
+ * (`takeStalePins`). Given, it is called INSTEAD of `onReload` — `x dev`'s child drains and exits
116
+ * for its supervisor to boot a fresh one. Absent, the save is `X_DEV_RESTART_REQUIRED` on `/_x`.
117
+ */
118
+ readonly onRestart?: (pins: readonly StalePin[]) => void;
111
119
  }
112
120
 
113
121
  /**
@@ -294,8 +302,14 @@ async function bootDev(
294
302
  state.islands = islands;
295
303
  for (const path of isr.store().paths()) isr.store().delete(path);
296
304
  state.reloads += 1;
297
- state.reloadFinding = undefined;
298
- options.onReload?.(file, Math.round(performance.now() - started));
305
+ // Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
306
+ const pins = takeStalePins();
307
+ state.reloadFinding =
308
+ pins.length > 0 && options.onRestart === undefined
309
+ ? restartFinding(options.root, pins)
310
+ : undefined;
311
+ if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
312
+ else options.onReload?.(file, Math.round(performance.now() - started));
299
313
  },
300
314
  // Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
301
315
  // a finding on `/_x`, never an unhandled rejection that takes the dev server down.
@@ -375,6 +389,7 @@ export const devCommand: CliCommand = {
375
389
  // The directory is CLAIMED from here down, so a boot that throws has to give it back — the
376
390
  // `releaseBoot` shape, with one acquisition. Without it the first failed `x dev` in a shell
377
391
  // refuses every later one with a pid that is no longer running.
392
+ const restart = childRestart(root, ctx.env);
378
393
  const server = await startDev({
379
394
  root,
380
395
  port,
@@ -384,6 +399,8 @@ export const devCommand: CliCommand = {
384
399
  if (!ctx.args.json)
385
400
  process.stdout.write(`${msg('cli.dev.hmr', { file, ms: durationMs })}\n`);
386
401
  },
402
+ // Supervised, a save this process cannot serve is a drain and an exit for a fresh child.
403
+ ...restart.options,
387
404
  }).catch((error: unknown) => {
388
405
  release();
389
406
  throw error;
@@ -461,12 +478,17 @@ export const devCommand: CliCommand = {
461
478
  // directory locked by a process that no longer exists.
462
479
  return {
463
480
  ...result,
464
- hold: holdUntilShutdown('dev', async () => {
465
- // The lock first: a stop() that throws must not leave a file claiming this pid still owns
466
- // the directory, because the next boot would then refuse for a process that is gone.
467
- clearLock(services.stateDir);
468
- await server.stop();
469
- }),
481
+ hold: holdUntilShutdown(
482
+ 'dev',
483
+ async () => {
484
+ // The lock first: a stop() that throws must not leave a file claiming this pid still owns
485
+ // the directory, because the next boot would then refuse for a process that is gone.
486
+ clearLock(services.stateDir);
487
+ await server.stop();
488
+ },
489
+ // Released — the port, the lock and the embedded database are free for the next child.
490
+ { exit: restart.exit },
491
+ ),
470
492
  };
471
493
  },
472
494
  };
@@ -26,6 +26,7 @@ import {
26
26
  islandVerdictJson,
27
27
  } from './island-verdict';
28
28
  import type { CommandResult } from './output';
29
+ import type { ShotCookiePair } from './shot-cookie';
29
30
  import type { ShotServer } from './shot-server';
30
31
  import { SHOT_DIR } from './shot-server';
31
32
 
@@ -152,6 +153,8 @@ export interface IslandShotInput {
152
153
  readonly settleMs: number;
153
154
  readonly timeoutMs: number;
154
155
  readonly extraHosts?: string | undefined;
156
+ /** `--cookie`, set in every state's session for the harness's own origin. */
157
+ readonly cookies?: readonly ShotCookiePair[] | undefined;
155
158
  readonly executablePath?: string | undefined;
156
159
  /** A provider's session, or a sidecar. One attach per viewport, memoised like a launch. */
157
160
  readonly cdpUrl?: string | undefined;
@@ -195,6 +198,7 @@ export async function islandShot(input: IslandShotInput): Promise<IslandArtifact
195
198
  settleMs: input.settleMs,
196
199
  timeoutMs: input.timeoutMs,
197
200
  ...(input.extraHosts === undefined ? {} : { extraHosts: input.extraHosts }),
201
+ ...(input.cookies === undefined ? {} : { cookies: input.cookies }),
198
202
  ...(input.minBytes === undefined ? {} : { minBytes: input.minBytes }),
199
203
  });
200
204
  }
@@ -225,6 +229,7 @@ export async function islandSweep(input: IslandSweepInput): Promise<IslandSweepA
225
229
  settleMs: input.settleMs,
226
230
  timeoutMs: input.timeoutMs,
227
231
  ...(input.extraHosts === undefined ? {} : { extraHosts: input.extraHosts }),
232
+ ...(input.cookies === undefined ? {} : { cookies: input.cookies }),
228
233
  ...(input.minBytes === undefined ? {} : { minBytes: input.minBytes }),
229
234
  });
230
235
  }
@@ -7,7 +7,7 @@ export const shotSpec: CommandSpec = {
7
7
  name: 'shot',
8
8
  summary: 'photograph one route, one island in a state it declares, or every island in the app',
9
9
  usage:
10
- 'x shot <route> [--locale <l>] [--theme light|dark] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--expect-status 404] [--port 0] [--out <dir>] [--settle 2000] [--json]',
10
+ 'x shot <route> [--locale <l>] [--theme light|dark] | --matrix [<route>] | --island <name> [--state <id>] | --all-islands [--cookie <name=value>] [--expect-status 404] [--port 0] [--out <dir>] [--settle 2000] [--json]',
11
11
  requiresApp: true,
12
12
  flags: [
13
13
  { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
@@ -28,6 +28,12 @@ export const shotSpec: CommandSpec = {
28
28
  summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
29
29
  },
30
30
  { name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
31
+ {
32
+ name: 'cookie',
33
+ type: 'string',
34
+ summary:
35
+ 'name=value[,name=value] set for the app before the first navigation (e.g. consent=granted)',
36
+ },
31
37
  {
32
38
  name: 'theme',
33
39
  type: 'string',
package/src/cmd-shot.ts CHANGED
@@ -32,6 +32,7 @@ import type { CommandResult } from './output';
32
32
  import type { ParsedArgs } from './parse';
33
33
  import { flagBool, flagString } from './parse';
34
34
  import { shotBrowserChoice } from './shot-browser';
35
+ import { readCookieFlag, type ShotCookiePair, shotCookies } from './shot-cookie';
35
36
  import {
36
37
  acceptLanguageHeaders,
37
38
  loadShotLocales,
@@ -218,6 +219,11 @@ export interface ShotRun {
218
219
  * app's default locale — so a picture never depends on the language of the machine's Chrome.
219
220
  */
220
221
  readonly acceptLanguage?: string | undefined;
222
+ /**
223
+ * `--cookie`: set before the first navigation, each for the server's own URL once it is up —
224
+ * so a consent banner (or any cookie-held choice) is decided in the picture.
225
+ */
226
+ readonly cookies?: readonly ShotCookiePair[] | undefined;
221
227
  readonly now?: (() => Date) | undefined;
222
228
  /**
223
229
  * `--expect-status`: the document status this shot is ok with — `404` to photograph the not-found
@@ -263,6 +269,9 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
263
269
  ...(options.acceptLanguage === undefined
264
270
  ? {}
265
271
  : { headers: acceptLanguageHeaders(options.acceptLanguage) }),
272
+ ...(options.cookies === undefined || options.cookies.length === 0
273
+ ? {}
274
+ : { cookies: shotCookies(options.cookies, new URL(server.url).toString()) }),
266
275
  });
267
276
  const page = session.page;
268
277
  if (options.colorScheme !== undefined) {
@@ -354,6 +363,7 @@ export const shotCommand: CliCommand = {
354
363
  const positional = ctx.args.positionals[0];
355
364
  const sweep = flagBool(ctx.args, 'all-islands');
356
365
  const matrix = flagBool(ctx.args, 'matrix');
366
+ const cookies = readCookieFlag(flagString(ctx.args, 'cookie'));
357
367
  const app = await loadShotLocales(root);
358
368
  const locale = readLocaleFlag(flagString(ctx.args, 'locale'), app);
359
369
  // Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
@@ -424,6 +434,7 @@ export const shotCommand: CliCommand = {
424
434
  ...(flagString(ctx.args, 'allow-hosts') === undefined
425
435
  ? {}
426
436
  : { extraHosts: flagString(ctx.args, 'allow-hosts') }),
437
+ ...(cookies.length === 0 ? {} : { cookies }),
427
438
  boot,
428
439
  };
429
440
  if (sweep) return islandSweepResult(await islandSweep(shared));
@@ -444,6 +455,7 @@ export const shotCommand: CliCommand = {
444
455
  fullPage: flagBool(ctx.args, 'full'),
445
456
  extraHosts: flagString(ctx.args, 'allow-hosts'),
446
457
  ...(expectStatus === undefined ? {} : { expectStatus }),
458
+ ...(cookies.length === 0 ? {} : { cookies }),
447
459
  };
448
460
  if (matrix) {
449
461
  // `--locale` and `--theme` narrow the matrix to one value of their axis.
package/src/dev-port.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { PORT_RANGE, readIntFlag } from './flag-number';
6
6
  import type { ParsedArgs } from './parse';
7
- import { portFromEnv } from './serve';
7
+ import { portFromEnv } from './serve-env';
8
8
 
9
9
  export const DEFAULT_DEV_PORT = 3000;
10
10
 
@@ -0,0 +1,178 @@
1
+ // `x dev` as two processes: this one supervises, a child serves. A save that reaches a module which
2
+ // DEFINES a primitive (`app-reload-graph.ts`'s pinned modules — a query over a slice's service, an
3
+ // admin page's view under `defineAdmin`) cannot be served by re-importing: the registries and the
4
+ // route table hold that module's first instance. The child exits `DEV_RESTART_EXIT_CODE` instead
5
+ // of logging "reloaded" over stale code, and this loop boots a fresh one on the same port. Every
6
+ // other save stays the in-process reload it was — a restart is paid only where nothing else works.
7
+
8
+ // why: Bun has no path API; the restart line names files relative to the app root.
9
+ import { relative } from 'node:path';
10
+ import { drain } from '@ultimat3/core';
11
+ import type { StalePin } from './app-reload-graph';
12
+ import { devSpec } from './cmd-dev-spec';
13
+ import { devPortFor } from './dev-port';
14
+ import { msg } from './messages';
15
+ import type { Finding } from './output';
16
+ import { parseArgs } from './parse';
17
+
18
+ /** Set on the child: it serves, and a pinned save makes it exit for a restart. */
19
+ export const DEV_CHILD_ENV = 'ULTIMATE_DEV_CHILD';
20
+ /** `EX_TEMPFAIL`: "try again" — the one exit code the supervisor answers with a new child. */
21
+ export const DEV_RESTART_EXIT_CODE = 75;
22
+
23
+ /** What the supervisor runs the child with: the argv, its port pinned. */
24
+ export interface DevSupervision {
25
+ readonly argv: readonly string[];
26
+ }
27
+
28
+ /**
29
+ * Whether this `x …` invocation is a supervised `x dev`, and with what child argv. Not for the
30
+ * child itself, `--once` (it boots, reports and exits — nothing to restart), `--help`, or an argv
31
+ * the parse refuses (`dispatch` reports that, in the child's place). `--port 0` is pinned to one
32
+ * free port here, so a restarted child binds the address the first one printed.
33
+ */
34
+ export function devSupervision(
35
+ argv: readonly string[],
36
+ env: Readonly<Record<string, string | undefined>>,
37
+ pickPort: () => number,
38
+ ): DevSupervision | undefined {
39
+ if (argv[0] !== 'dev' || env[DEV_CHILD_ENV] === '1') return undefined;
40
+ try {
41
+ const args = parseArgs(argv, [devSpec]);
42
+ if (args.help || args.flags.get('once') === true) return undefined;
43
+ return { argv: devPortFor(args, env) === 0 ? withPort(argv, pickPort()) : argv };
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ /** A port nothing holds right now, asked of the OS — what `--port 0` is pinned to. */
50
+ export function freeDevPort(): number {
51
+ const probe = Bun.serve({ port: 0, fetch: () => new Response() });
52
+ const port = probe.port ?? 0;
53
+ probe.stop(true);
54
+ return port;
55
+ }
56
+
57
+ /** `argv` with every `--port` removed and `--port <port>` appended. */
58
+ function withPort(argv: readonly string[], port: number): readonly string[] {
59
+ const out: string[] = [];
60
+ for (let index = 0; index < argv.length; index += 1) {
61
+ const token = argv[index] ?? '';
62
+ if (token === '--port') {
63
+ index += 1;
64
+ continue;
65
+ }
66
+ if (token.startsWith('--port=')) continue;
67
+ out.push(token);
68
+ }
69
+ return [...out, '--port', String(port)];
70
+ }
71
+
72
+ /** The child as the loop sees it — `Bun.spawn`'s answer, narrowed so a test can hand one in. */
73
+ export interface DevChild {
74
+ readonly exited: Promise<number>;
75
+ kill(signal: 'SIGINT' | 'SIGTERM'): void;
76
+ }
77
+
78
+ export interface SuperviseDevInput {
79
+ readonly bin: string;
80
+ readonly argv: readonly string[];
81
+ readonly env: Readonly<Record<string, string | undefined>>;
82
+ readonly spawn?: (
83
+ command: readonly string[],
84
+ env: Record<string, string | undefined>,
85
+ ) => DevChild;
86
+ }
87
+
88
+ const spawnInherited = (
89
+ command: readonly string[],
90
+ env: Record<string, string | undefined>,
91
+ ): DevChild =>
92
+ Bun.spawn([...command], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env });
93
+
94
+ /**
95
+ * Runs children until one exits with anything but `DEV_RESTART_EXIT_CODE`, and answers that code.
96
+ * SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches the child on its own too, and its
97
+ * drain is idempotent — and once one arrived no child is started again: a Ctrl-C during a restart
98
+ * is a stop, never a respawn.
99
+ */
100
+ export async function superviseDev(input: SuperviseDevInput): Promise<number> {
101
+ const spawn = input.spawn ?? spawnInherited;
102
+ let child: DevChild | undefined;
103
+ let stopping = false;
104
+ const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
105
+ stopping = true;
106
+ child?.kill(signal);
107
+ };
108
+ const onInt = forward('SIGINT');
109
+ const onTerm = forward('SIGTERM');
110
+ process.on('SIGINT', onInt);
111
+ process.on('SIGTERM', onTerm);
112
+ try {
113
+ for (;;) {
114
+ child = spawn([process.execPath, input.bin, ...input.argv], {
115
+ ...input.env,
116
+ [DEV_CHILD_ENV]: '1',
117
+ });
118
+ const code = await child.exited;
119
+ if (code !== DEV_RESTART_EXIT_CODE || stopping) return code;
120
+ }
121
+ } finally {
122
+ process.off('SIGINT', onInt);
123
+ process.off('SIGTERM', onTerm);
124
+ }
125
+ }
126
+
127
+ /** `apps/web/app/greet/service.ts under apps/web/app/greet/queries.ts`, one per pinned module. */
128
+ export function restartReason(root: string, pins: readonly StalePin[]): string {
129
+ return pins
130
+ .map(({ changed, pinned }) =>
131
+ changed === pinned
132
+ ? relative(root, pinned)
133
+ : `${relative(root, changed)} under ${relative(root, pinned)}`,
134
+ )
135
+ .join(', ');
136
+ }
137
+
138
+ /** `/_x`'s finding for a process nothing supervises (an embedded `startDev()` with no `onRestart`). */
139
+ export function restartFinding(root: string, pins: readonly StalePin[]): Finding {
140
+ const first = pins[0];
141
+ return {
142
+ code: 'X_DEV_RESTART_REQUIRED',
143
+ cause: `${restartReason(root, pins)} — a module that defines a primitive still holds the code before the save, and re-importing it would register a second definition`,
144
+ fix: 'restart x dev (a supervised `x dev` restarts itself; an embedded startDev() passes onRestart)',
145
+ at: first === undefined ? '' : relative(root, first.pinned),
146
+ };
147
+ }
148
+
149
+ /** The child's half: what `startDev` is handed, and what the hold calls once released. */
150
+ export interface ChildRestart {
151
+ readonly options: { readonly onRestart?: (pins: readonly StalePin[]) => void };
152
+ readonly exit: (code: number) => void;
153
+ }
154
+
155
+ /**
156
+ * Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
157
+ * starts core's drain — the hold then releases the lock, the port and the embedded database — and
158
+ * exits `DEV_RESTART_EXIT_CODE` once released. Anything else keeps the finding on `/_x`.
159
+ */
160
+ export function childRestart(
161
+ root: string,
162
+ env: Readonly<Record<string, string | undefined>>,
163
+ ): ChildRestart {
164
+ if (env[DEV_CHILD_ENV] !== '1') return { options: {}, exit: () => undefined };
165
+ let restarting = false;
166
+ return {
167
+ options: {
168
+ onRestart: (pins) => {
169
+ process.stderr.write(`${msg('cli.dev.restart', { reason: restartReason(root, pins) })}\n`);
170
+ restarting = true;
171
+ void drain('restart');
172
+ },
173
+ },
174
+ exit: () => {
175
+ if (restarting) process.exit(DEV_RESTART_EXIT_CODE);
176
+ },
177
+ };
178
+ }
@@ -203,6 +203,7 @@ export const CLI_OWNED_ERROR_CODES = [
203
203
  'X_VERIFY_MERGE_INCOMPLETE',
204
204
  'X_VERIFY_MERGE_INPUT',
205
205
  'X_SITEMAP_EXTRA_INVALID',
206
+ 'X_DEV_RESTART_REQUIRED',
206
207
  ] as const;
207
208
 
208
209
  /**
@@ -355,6 +356,8 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
355
356
  X_VERIFY_MERGE_INCOMPLETE: 'x verify merge was handed parts that do not add up to the whole gate',
356
357
  X_VERIFY_MERGE_INPUT: 'a part handed to x verify merge is not an x verify --json document',
357
358
  X_SITEMAP_EXTRA_INVALID: 'a seo.sitemap.extra path names no public page',
359
+ X_DEV_RESTART_REQUIRED:
360
+ 'a save reached a module that defines a primitive; only a new process serves it',
358
361
  };
359
362
 
360
363
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -14,6 +14,7 @@ import { readinessProbe } from './island-harness-script';
14
14
  import { IslandRequestUnstubbedError, IslandUnphotographableError } from './island-shot-errors';
15
15
  import type { IslandReadiness, IslandStateShot } from './island-verdict';
16
16
  import { parseReadiness } from './island-verdict';
17
+ import { type ShotCookiePair, shotCookies } from './shot-cookie';
17
18
  import type { ShotServer } from './shot-server';
18
19
  import { allowHostsFrom } from './shot-server';
19
20
  import { SETTLE_POLL_MS, settleReadiness } from './shot-settle';
@@ -120,7 +121,8 @@ export function photographFault(
120
121
  }
121
122
 
122
123
  /**
123
- * The capture rectangle for a readiness answer, in PAGE coordinates: the crop target's own box,
124
+ * The capture rectangle for a readiness answer, in PAGE coordinates: the crop target's frame (its
125
+ * own box grown to every open popup),
124
126
  * translated out of the viewport coordinates `getBoundingClientRect()` answers in, then grown by
125
127
  * `margin` on every side and CLAMPED to the document.
126
128
  *
@@ -136,10 +138,13 @@ export function photographFault(
136
138
  * the parser's floor and not a second opinion.
137
139
  */
138
140
  export function clipFor(seen: IslandReadiness | null, margin: number): CaptureClip {
139
- const x = (seen?.box.x ?? 0) + (seen?.scroll.x ?? 0);
140
- const y = (seen?.box.y ?? 0) + (seen?.scroll.y ?? 0);
141
- const width = seen?.box.width ?? 0;
142
- const height = seen?.box.height ?? 0;
141
+ // The frame, not the box: an open listbox, menu or popover lies outside the island's own box,
142
+ // and cropping to the box photographed an `open` state closed. The frame always contains the box.
143
+ const framed = seen?.frame ?? seen?.box;
144
+ const x = (framed?.x ?? 0) + (seen?.scroll.x ?? 0);
145
+ const y = (framed?.y ?? 0) + (seen?.scroll.y ?? 0);
146
+ const width = framed?.width ?? 0;
147
+ const height = framed?.height ?? 0;
143
148
  const left = Math.max(0, x - margin);
144
149
  const top = Math.max(0, y - margin);
145
150
  const right = Math.min(seen?.page.width ?? x + width, x + width + margin);
@@ -159,6 +164,8 @@ export interface IslandCaptureRun {
159
164
  readonly timeoutMs: number;
160
165
  readonly extraHosts?: string | undefined;
161
166
  readonly cropMarginPx?: number | undefined;
167
+ /** `--cookie`, scoped to the harness's server once it is up. */
168
+ readonly cookies?: readonly ShotCookiePair[] | undefined;
162
169
  }
163
170
 
164
171
  const quietly = async (stop: () => Promise<void>): Promise<void> => {
@@ -189,6 +196,9 @@ export async function captureIslandState(
189
196
  rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
190
197
  clock: systemShotClock,
191
198
  timeoutMs: options.timeoutMs,
199
+ ...(options.cookies === undefined || options.cookies.length === 0
200
+ ? {}
201
+ : { cookies: shotCookies(options.cookies, new URL(server.url).toString()) }),
192
202
  });
193
203
  const page = session.page;
194
204
  // BEFORE the navigation, so the first paint already has it: `prefers-color-scheme` is a live
@@ -179,11 +179,28 @@ export function harnessScript(options: HarnessScriptOptions): string {
179
179
  *
180
180
  * `selector` is the crop target the manifest declared; the island's own host element when absent.
181
181
  */
182
+ /**
183
+ * What an island shows OUTSIDE its own box while a state is open: a combobox's listbox, a menu, a
184
+ * popover or a dialog — often portalled to `<body>`, so no descendant walk would find it. The crop
185
+ * is the union of the target's box and every visible match, so an `open` state photographs open.
186
+ * `:popover-open` is last and dropped on an engine that cannot parse it.
187
+ */
188
+ export const POPUP_SELECTOR =
189
+ '[role=listbox],[role=menu],[role=dialog],[role=alertdialog],[role=tooltip],dialog[open],:popover-open';
190
+
182
191
  export const readinessProbe = (selector: string): string =>
183
192
  `(function(){var W=window.${HARNESS_GLOBAL}||{};` +
184
193
  'var host=document.querySelector("[data-x-island]");' +
185
194
  `var box=document.querySelector(${JSON.stringify(selector)})||host;` +
186
195
  'var r=box?box.getBoundingClientRect():{width:0,height:0,x:0,y:0};' +
196
+ // The frame: the box grown to every open popup the page shows, in the same viewport coordinates.
197
+ 'var fl=r.x,ft=r.y,fr=r.x+r.width,fb=r.y+r.height,ps=[];' +
198
+ `try{ps=document.querySelectorAll(${JSON.stringify(POPUP_SELECTOR)})}` +
199
+ `catch(e){ps=document.querySelectorAll(${JSON.stringify(POPUP_SELECTOR.replace(',:popover-open', ''))})}` +
200
+ 'for(var i=0;box&&i<ps.length;i++){var s=getComputedStyle(ps[i]);' +
201
+ 'if(s.display==="none"||s.visibility==="hidden")continue;var q=ps[i].getBoundingClientRect();' +
202
+ 'if(q.width===0||q.height===0)continue;fl=Math.min(fl,q.x);ft=Math.min(ft,q.y);' +
203
+ 'fr=Math.max(fr,q.x+q.width);fb=Math.max(fb,q.y+q.height)}' +
187
204
  'return{harness:W.harness===true,ready:W.ready===true,' +
188
205
  'unstubbed:(W.unstubbed||[]).slice(),' +
189
206
  // Recorded beside `unstubbed`, never merged into it: a socket a component opened and never
@@ -198,6 +215,7 @@ export const readinessProbe = (selector: string): string =>
198
215
  // and rendered nothing is the silence a non-zero box would otherwise read as success.
199
216
  'filled:box?(box.children.length>0||(box.textContent||"").trim().length>0):false,' +
200
217
  'box:{x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)},' +
218
+ 'frame:{x:Math.round(fl),y:Math.round(ft),width:Math.round(fr-fl),height:Math.round(fb-ft)},' +
201
219
  // The box is VIEWPORT coordinates — what `getBoundingClientRect()` answers — and a capture clip
202
220
  // is PAGE coordinates. They agree only while the page is at the origin, which is the one case a
203
221
  // harness happens to be in and is not a rule anything enforces: a state whose component sits
@@ -67,6 +67,11 @@ export interface IslandReadiness {
67
67
  readonly filled: boolean;
68
68
  /** The crop target's rectangle in VIEWPORT coordinates, which is what the DOM answers. */
69
69
  readonly box: IslandBox;
70
+ /**
71
+ * `box` grown to every open popup the page shows (`POPUP_SELECTOR`), same coordinates: what the
72
+ * picture is cropped to, so a listbox or a portalled menu is in it. Absent: the box alone.
73
+ */
74
+ readonly frame?: IslandBox | undefined;
70
75
  /**
71
76
  * The page's scroll offset at the moment the box was measured. A capture clip is in PAGE
72
77
  * coordinates, so this is what turns one into the other — and it is a separate field rather than
@@ -99,6 +104,7 @@ const readinessSchema: StandardSchemaV1<unknown, IslandReadiness> = t.object({
99
104
  failed: t.nullable(t.string),
100
105
  filled: t.boolean,
101
106
  box: t.object({ x: t.number, y: t.number, width: t.number, height: t.number }),
107
+ frame: t.optional(t.object({ x: t.number, y: t.number, width: t.number, height: t.number })),
102
108
  scroll: t.object({ x: t.number, y: t.number }),
103
109
  overflow: t.object({ x: t.boolean, y: t.boolean }),
104
110
  page: t.object({ width: t.number, height: t.number }),
package/src/mcp-errors.ts CHANGED
@@ -253,6 +253,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
253
253
  'x verify --only budgets --json # after moving each await in Page into export const load',
254
254
  X_SITEMAP_EXTRA_INVALID:
255
255
  'x routes --json # then list in seo.sitemap.extra only a path a registered app/ route without a policy answers',
256
+ X_DEV_RESTART_REQUIRED:
257
+ 'x dev --json # a fresh process; the supervised x dev restarts itself on such a save',
256
258
  };
257
259
 
258
260
  const isCliCode = (code: string): code is CliErrorCode =>
package/src/messages.ts CHANGED
@@ -76,6 +76,7 @@ const CATALOG = {
76
76
  'cli.dev.mail.external': 'mail=external({driver} via {detail})',
77
77
  'cli.dev.mail.refused': 'mail=refused({detail})',
78
78
  'cli.dev.hmr': 'reloaded {file} in {ms}ms',
79
+ 'cli.dev.restart': 'restarting: {reason} — a module that defines a primitive holds the old code',
79
80
  // A hard kill leaves the lock behind and that is normal, not a fault — worth one line so a
80
81
  // reader knows why the boot paused, and never a finding.
81
82
  'cli.dev.staleLock': 'cleared a stale dev.lock — the previous x dev did not shut down cleanly',
@@ -0,0 +1,45 @@
1
+ // `x shot --cookie name=value[,name=value]` — cookies set in the browser before the first
2
+ // navigation, so a page is photographed with a cookie-held choice (consent, a dismissed banner)
3
+ // already made. Read here, before anything boots; scoped to the app's own origin once it exists.
4
+
5
+ import type { ShotCookie } from './browser-launcher-port';
6
+ import { BadFlagError } from './errors';
7
+
8
+ /** RFC 6265 `token` for a name; a value may not carry what ends or splits a Cookie header. */
9
+ const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
10
+ const COOKIE_VALUE = /^[^\s;,"\\]*$/;
11
+
12
+ const FIX = 'x shot / --cookie consent=granted --json';
13
+
14
+ export interface ShotCookiePair {
15
+ readonly name: string;
16
+ readonly value: string;
17
+ }
18
+
19
+ const refuse = (reason: string): never => {
20
+ throw new BadFlagError({ flag: 'cookie', command: 'shot', reason, fix: FIX });
21
+ };
22
+
23
+ /** The pairs `--cookie` names, in order; absent or empty is none. */
24
+ export function readCookieFlag(raw: string | undefined): readonly ShotCookiePair[] {
25
+ if (raw === undefined || raw.trim() === '') return [];
26
+ return raw
27
+ .split(',')
28
+ .map((part) => part.trim())
29
+ .filter((part) => part !== '')
30
+ .map((pair) => {
31
+ const at = pair.indexOf('=');
32
+ if (at < 0) return refuse(`"${pair}" has no "=" — a cookie is name=value`);
33
+ const name = pair.slice(0, at).trim();
34
+ const value = pair.slice(at + 1).trim();
35
+ if (!COOKIE_NAME.test(name)) return refuse(`"${name}" is not a cookie name`);
36
+ if (!COOKIE_VALUE.test(value)) {
37
+ return refuse(`the value of "${name}" holds a space, ";", ",", a quote or a backslash`);
38
+ }
39
+ return { name, value };
40
+ });
41
+ }
42
+
43
+ /** Each pair for the app under test: the server's own URL, so no cookie reaches another host. */
44
+ export const shotCookies = (pairs: readonly ShotCookiePair[], url: string): readonly ShotCookie[] =>
45
+ pairs.map((pair) => ({ ...pair, url }));