@ultimat3/cli 22.12.0 → 22.13.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.12.0",
3
+ "version": "22.13.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.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",
41
+ "@ultimat3/action": "22.13.0",
42
+ "@ultimat3/admin": "22.13.0",
43
+ "@ultimat3/ai": "22.13.0",
44
+ "@ultimat3/auth": "22.13.0",
45
+ "@ultimat3/cache": "22.13.0",
46
+ "@ultimat3/core": "22.13.0",
47
+ "@ultimat3/db": "22.13.0",
48
+ "@ultimat3/entity": "22.13.0",
49
+ "@ultimat3/flags": "22.13.0",
50
+ "@ultimat3/http": "22.13.0",
51
+ "@ultimat3/i18n": "22.13.0",
52
+ "@ultimat3/jobs": "22.13.0",
53
+ "@ultimat3/mail": "22.13.0",
54
+ "@ultimat3/manifest": "22.13.0",
55
+ "@ultimat3/mcp": "22.13.0",
56
+ "@ultimat3/money": "22.13.0",
57
+ "@ultimat3/notify": "22.13.0",
58
+ "@ultimat3/policy": "22.13.0",
59
+ "@ultimat3/pwa": "22.13.0",
60
+ "@ultimat3/query": "22.13.0",
61
+ "@ultimat3/realtime": "22.13.0",
62
+ "@ultimat3/render": "22.13.0",
63
+ "@ultimat3/schema": "22.13.0",
64
+ "@ultimat3/seo": "22.13.0",
65
+ "@ultimat3/storage": "22.13.0",
66
+ "@ultimat3/testing": "22.13.0",
67
+ "@ultimat3/time": "22.13.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/budgets.ts CHANGED
@@ -60,8 +60,9 @@ export interface BuildStats {
60
60
  * rule stops being read as a measurement. `.x/` survives across framework upgrades, and
61
61
  * `examples/dummy` was charged 250 B for `/x-sw-register.js` by a file written before
62
62
  * `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
63
+ * `3`: an island entry's imported chunks are charged, each once per document (shared chunks).
63
64
  */
64
- export const BUILD_STATS_RULES = 2;
65
+ export const BUILD_STATS_RULES = 3;
65
66
 
66
67
  const chainOf = (stats: RouteStats): string =>
67
68
  stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
@@ -252,6 +253,31 @@ const carriesJson = (attrs: string): boolean => {
252
253
  */
253
254
  const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
254
255
 
256
+ /**
257
+ * Static imports and `import()` both. A chunk reached by `import()` was INLINED into its island
258
+ * before islands were split, so charging it keeps a route's number what it was; and it is fetched
259
+ * the moment the island needs it, which is before the page does what it was loaded for.
260
+ */
261
+ const LOADS: ReadonlySet<string> = new Set(['import-statement', 'dynamic-import']);
262
+
263
+ /** Same-origin paths `code`, served at `url`, makes the browser load. Cross-origin and bare are not this build's. */
264
+ function loadedBy(url: string, code: string): readonly string[] {
265
+ let imports: readonly { readonly path: string; readonly kind: string }[];
266
+ try {
267
+ imports = new Bun.Transpiler({ loader: 'js' }).scanImports(code);
268
+ } catch {
269
+ // A file that does not parse as JavaScript imports nothing this gate can name; its own bytes
270
+ // are already charged.
271
+ return [];
272
+ }
273
+ const base = new URL(url, 'https://artifact.invalid');
274
+ return imports
275
+ .filter((one) => LOADS.has(one.kind) && /^\.{0,2}\//.test(one.path))
276
+ .map((one) => new URL(one.path, base))
277
+ .filter((target) => target.origin === base.origin)
278
+ .map((target) => target.pathname);
279
+ }
280
+
255
281
  /** One executable module the document names, and what it weighs on disk. */
256
282
  export interface MeasuredEntry {
257
283
  readonly url: string;
@@ -337,7 +363,8 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
337
363
  if (!url.startsWith('/') || fetched.has(url)) return;
338
364
  fetched.add(url);
339
365
  const file = Bun.file(join(out, url.slice(1)));
340
- const bytes = (await file.exists()) ? file.size : 0;
366
+ const exists = await file.exists();
367
+ const bytes = exists ? file.size : 0;
341
368
  // Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
342
369
  // Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
343
370
  // to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
@@ -348,6 +375,12 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
348
375
  }
349
376
  entries.push({ url, bytes });
350
377
  jsBytes += bytes;
378
+ // And every file THAT one loads: an island entry imports its shared chunks (`island-link.ts`),
379
+ // and each is a fetch the browser makes before the island is whole. Through `weigh`, so a chunk
380
+ // two islands share is charged once and a cycle ends at the first repeat.
381
+ if (exists) {
382
+ for (const next of loadedBy(url, await file.text())) await weigh(next);
383
+ }
351
384
  };
352
385
 
353
386
  for (const match of html.matchAll(SCRIPT_TAG)) {
@@ -0,0 +1,144 @@
1
+ // A supervised `x dev` child that outlives what it serves stops itself. Two facts nothing else in
2
+ // the child watched: its SUPERVISOR is gone (SIGKILLed, so it forwarded nothing — the child was
3
+ // reparented to init and served forever), and its APP ROOT is gone (the jobs worker, the outbox
4
+ // relay and the scheduler then poll an embedded database whose directory no longer exists, at
5
+ // 100% CPU, measured at ~50 MB/s of heap growth). Found on a laptop, 2026-09-29: two children of
6
+ // `cmd-dev-restart.live.test.ts`, six hours old, ppid 1, cwd deleted, one at 7 GB.
7
+ //
8
+ // And a stop that does not finish is finished for it. A drain is bounded by core's deadline, but a
9
+ // loop starved by a spin like the one above reaches its timers late or never, so every stop this
10
+ // child begins — a signal, the watch below, a restart — carries a hard exit past that bound.
11
+
12
+ import { existsSync } from 'node:fs'; // why: one synchronous stat per tick; Bun has no sync exists.
13
+ import { drain, drainDeadlineMs, ERROR_DOCS_URL, finiteCount, logger } from '@ultimat3/core';
14
+ import { MIN_RELEASE_MS } from './hold';
15
+
16
+ /** Set on the child by the supervisor: its own pid, so a child started after it died still knows. */
17
+ export const DEV_SUPERVISOR_PID_ENV = 'ULTIMATE_DEV_SUPERVISOR_PID';
18
+
19
+ /** How often the child asks. A second: a dead supervisor's child is gone about a second later. */
20
+ export const DEV_CHILD_WATCH_MS = 1_000;
21
+
22
+ /** Past the drain's own deadline and `hold.ts`'s release floor, a stop is not going to finish. */
23
+ export const devHardExitMs = (): number => drainDeadlineMs() + MIN_RELEASE_MS + 5_000;
24
+
25
+ export type DevChildGone = 'supervisor' | 'root';
26
+
27
+ export interface DevChildWatchInput {
28
+ readonly root: string;
29
+ readonly env: Readonly<Record<string, string | undefined>>;
30
+ /** Called once, when the child decides to leave. */
31
+ readonly leave: (why: DevChildGone) => void;
32
+ /** Test seams: the defaults read the process and the disk. */
33
+ readonly parentPid?: () => number;
34
+ readonly rootExists?: (root: string) => boolean;
35
+ readonly intervalMs?: number;
36
+ }
37
+
38
+ /** The supervisor's pid, or `undefined` when this process was not started by one. */
39
+ export function supervisorPid(
40
+ env: Readonly<Record<string, string | undefined>>,
41
+ ): number | undefined {
42
+ const said = Number(env[DEV_SUPERVISOR_PID_ENV]);
43
+ return Number.isInteger(said) && said > 0 ? said : undefined;
44
+ }
45
+
46
+ /**
47
+ * Polls both facts until one fails, then calls `leave` once and stops. Answers the stop.
48
+ * `process.ppid` rather than a pipe from the supervisor: stdin is the terminal's (`inherit`), and
49
+ * a supervisor that died before the first tick is still caught — its pid is never ours again.
50
+ */
51
+ export function watchDevChild(input: DevChildWatchInput): () => void {
52
+ const expected = supervisorPid(input.env);
53
+ const parent = input.parentPid ?? (() => process.ppid);
54
+ const exists = input.rootExists ?? existsSync;
55
+ let left = false;
56
+ const timer = setInterval(
57
+ () => {
58
+ if (left) return;
59
+ const why: DevChildGone | undefined =
60
+ expected !== undefined && parent() !== expected
61
+ ? 'supervisor'
62
+ : exists(input.root)
63
+ ? undefined
64
+ : 'root';
65
+ if (why === undefined) return;
66
+ left = true;
67
+ clearInterval(timer);
68
+ input.leave(why);
69
+ },
70
+ finiteCount('watchDevChild', 'intervalMs', input.intervalMs ?? DEV_CHILD_WATCH_MS, 1),
71
+ );
72
+ // Never the reason the process is alive — the hold is, and a watch outliving it would be a leak.
73
+ timer.unref();
74
+ return () => {
75
+ left = true;
76
+ clearInterval(timer);
77
+ };
78
+ }
79
+
80
+ /** The line the child leaves on stderr (fd 1 may be `--json`'s one document). */
81
+ export function reportGone(root: string, why: DevChildGone): void {
82
+ if (why === 'root') {
83
+ logger.error('X_DEV_ROOT_GONE', {
84
+ code: 'X_DEV_ROOT_GONE',
85
+ cause: `${root}, the app root this x dev serves, no longer exists — nothing is left to serve, so it stops instead of polling a database whose directory is gone`,
86
+ fix: 'x dev # from the app root, once it exists again (restored, re-cloned or moved back)',
87
+ docs: ERROR_DOCS_URL,
88
+ });
89
+ return;
90
+ }
91
+ logger.warn('dev.supervisor.gone', {
92
+ cause:
93
+ 'the x dev supervisor that started this child exited without stopping it (a SIGKILL cannot be forwarded), so the child stops itself',
94
+ });
95
+ }
96
+
97
+ /**
98
+ * One hard exit, armed by the first stop and never disarmed: it is NOT unref'd, because it is the
99
+ * one timer that must still fire once everything else is done or stuck. `exit` is the test seam.
100
+ */
101
+ export function hardExit(
102
+ exit: (code: number) => void,
103
+ afterMs: () => number = devHardExitMs,
104
+ ): (code: number) => void {
105
+ let armed = false;
106
+ return (code: number): void => {
107
+ if (armed) return;
108
+ armed = true;
109
+ const ms = afterMs();
110
+ setTimeout(() => {
111
+ logger.warn('dev.stop.forced', {
112
+ cause: `x dev was still stopping ${String(ms)}ms after it began, so it exits without finishing`,
113
+ });
114
+ exit(code);
115
+ }, ms);
116
+ };
117
+ }
118
+
119
+ /**
120
+ * The child's watch, wired to core's drain: the supervisor's death or the root's, and SIGINT or
121
+ * SIGTERM, each begin a stop that ends within `devHardExitMs` however the drain fares. `onGone`
122
+ * marks why, so the hold's exit can answer a code.
123
+ */
124
+ export function startDevChildWatch(
125
+ root: string,
126
+ env: Readonly<Record<string, string | undefined>>,
127
+ onGone: (why: DevChildGone) => void,
128
+ ): { readonly stopping: (code: number) => void } {
129
+ const stopping = hardExit((code) => process.exit(code));
130
+ // Beside core's own handlers, never instead of them: the drain is theirs, the deadline is this.
131
+ process.on('SIGINT', () => stopping(130));
132
+ process.on('SIGTERM', () => stopping(143));
133
+ watchDevChild({
134
+ root,
135
+ env,
136
+ leave: (why) => {
137
+ reportGone(root, why);
138
+ onGone(why);
139
+ stopping(why === 'root' ? 1 : 0);
140
+ void drain(why === 'root' ? 'dev-root-gone' : 'dev-supervisor-gone');
141
+ },
142
+ });
143
+ return { stopping };
144
+ }
@@ -0,0 +1,122 @@
1
+ // The `x dev` live tests' process discipline, in one place: spawn the supervisor, read its two
2
+ // streams, and — on every path, a failing one included — reap every process it started and prove
3
+ // none is left. Two children of `cmd-dev-restart.live.test.ts` ran for six hours with ppid 1 and a
4
+ // deleted cwd because the test's `finally` SIGKILLed the supervisor, which cannot forward a signal
5
+ // it never gets, and nothing then looked for what it had started (2026-09-29).
6
+
7
+ import { readdirSync, readlinkSync } from 'node:fs'; // why: /proc is read synchronously; Bun has no readlink.
8
+
9
+ /** One pump per stream — reading a stream twice closes its reader under the first `for await`. */
10
+ export function pump(stream: ReadableStream<Uint8Array>): { seen: () => string } {
11
+ const decoder = new TextDecoder();
12
+ let seen = '';
13
+ void (async () => {
14
+ for await (const chunk of stream) seen += decoder.decode(chunk, { stream: true });
15
+ })();
16
+ return { seen: () => seen };
17
+ }
18
+
19
+ /** Poll the buffer until `marker` shows up. The caller's own timeout is the deadline. */
20
+ export async function waitFor(output: { seen: () => string }, marker: string): Promise<string> {
21
+ for (;;) {
22
+ const seen = output.seen();
23
+ if (seen.includes(marker)) return seen;
24
+ await Bun.sleep(25);
25
+ }
26
+ }
27
+
28
+ /** Whether `pid` is still a process — signal 0 delivers nothing and only asks. */
29
+ export function alive(pid: number): boolean {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /** Every descendant of `pid`, depth first — asked while `pid` lives, or its children are init's. */
39
+ export function descendantsOf(pid: number): readonly number[] {
40
+ const out = Bun.spawnSync(['pgrep', '-P', String(pid)]).stdout.toString();
41
+ const children = out
42
+ .split('\n')
43
+ .map((line) => Number(line.trim()))
44
+ .filter((child) => Number.isInteger(child) && child > 0);
45
+ return children.flatMap((child) => [child, ...descendantsOf(child)]);
46
+ }
47
+
48
+ /**
49
+ * Every process whose cwd is `dir` — a deleted one included, which Linux spells `<dir> (deleted)`.
50
+ * The one question that finds an orphan after its parent is gone, when no process tree reaches it.
51
+ * Empty where there is no `/proc` (macOS): the descendant walk is then the whole answer.
52
+ */
53
+ export function processesIn(dir: string): readonly number[] {
54
+ let pids: readonly string[];
55
+ try {
56
+ pids = readdirSync('/proc').filter((name) => /^\d+$/.test(name));
57
+ } catch {
58
+ return [];
59
+ }
60
+ const found: number[] = [];
61
+ for (const pid of pids) {
62
+ try {
63
+ const cwd = readlinkSync(`/proc/${pid}/cwd`);
64
+ if (cwd === dir || cwd === `${dir} (deleted)`) found.push(Number(pid));
65
+ } catch {
66
+ // Gone between the listing and the read, or not ours to read.
67
+ }
68
+ }
69
+ return found;
70
+ }
71
+
72
+ /** Whether anything still answers on `port` at the loopback address `x dev` binds. */
73
+ export async function listening(port: number): Promise<boolean> {
74
+ try {
75
+ const socket = await Bun.connect({
76
+ hostname: '127.0.0.1',
77
+ port,
78
+ socket: { data: () => undefined },
79
+ });
80
+ socket.end();
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * SIGKILL `pids`, then wait until each is gone. A test's last resort, run in `finally` — by then
89
+ * every graceful stop the test meant to prove has had its chance. `performance.now()`, never
90
+ * `Date.now()`: the test preload freezes the wall clock, and a deadline read off it never arrives.
91
+ */
92
+ export async function reap(pids: readonly number[], timeoutMs = 10_000): Promise<void> {
93
+ for (const pid of pids) {
94
+ try {
95
+ process.kill(pid, 'SIGKILL');
96
+ } catch {
97
+ // Already gone.
98
+ }
99
+ }
100
+ const until = performance.now() + timeoutMs;
101
+ while (pids.some(alive) && performance.now() < until) await Bun.sleep(25);
102
+ }
103
+
104
+ /** What was left: the pids still in `dir` and whether `port` still answers. Both empty is clean. */
105
+ export async function leftovers(
106
+ dir: string,
107
+ port: number | undefined,
108
+ ): Promise<{ readonly pids: readonly number[]; readonly port: boolean }> {
109
+ return {
110
+ pids: processesIn(dir),
111
+ port: port === undefined ? false : await listening(port),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * The net under a TIMED-OUT test: bun abandons its body without running `finally`, so an
117
+ * `afterEach` reaps whatever still runs in the fixture directory — the only handle left on an
118
+ * orphan whose parent is gone.
119
+ */
120
+ export async function reapIn(dir: string): Promise<void> {
121
+ await reap(processesIn(dir));
122
+ }
@@ -10,6 +10,8 @@ import { relative } from 'node:path';
10
10
  import { drain } from '@ultimat3/core';
11
11
  import type { StalePin } from './app-reload-graph';
12
12
  import { devSpec } from './cmd-dev-spec';
13
+ import type { DevChildGone } from './dev-child-watch';
14
+ import { DEV_SUPERVISOR_PID_ENV, startDevChildWatch, supervisorPid } from './dev-child-watch';
13
15
  import { devPortFor } from './dev-port';
14
16
  import { msg } from './messages';
15
17
  import type { Finding } from './output';
@@ -96,10 +98,16 @@ const spawnInherited = (
96
98
  * SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches the child on its own too, and its
97
99
  * drain is idempotent — and once one arrived no child is started again: a Ctrl-C during a restart
98
100
  * is a stop, never a respawn.
101
+ *
102
+ * Every other way out stops the child too: an uncaught error or a `process.exit` here reaches the
103
+ * `exit` listener, which SIGTERMs a child still running. The one way no listener sees is a SIGKILL
104
+ * of this process — the child's own watch (`dev-child-watch.ts`) answers that, from its side, off
105
+ * the pid this loop hands it.
99
106
  */
100
107
  export async function superviseDev(input: SuperviseDevInput): Promise<number> {
101
108
  const spawn = input.spawn ?? spawnInherited;
102
109
  let child: DevChild | undefined;
110
+ let running = false;
103
111
  let stopping = false;
104
112
  const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
105
113
  stopping = true;
@@ -107,20 +115,28 @@ export async function superviseDev(input: SuperviseDevInput): Promise<number> {
107
115
  };
108
116
  const onInt = forward('SIGINT');
109
117
  const onTerm = forward('SIGTERM');
118
+ const onExit = (): void => {
119
+ if (running) child?.kill('SIGTERM');
120
+ };
110
121
  process.on('SIGINT', onInt);
111
122
  process.on('SIGTERM', onTerm);
123
+ process.on('exit', onExit);
112
124
  try {
113
125
  for (;;) {
114
126
  child = spawn([process.execPath, input.bin, ...input.argv], {
115
127
  ...input.env,
116
128
  [DEV_CHILD_ENV]: '1',
129
+ [DEV_SUPERVISOR_PID_ENV]: String(process.pid),
117
130
  });
131
+ running = true;
118
132
  const code = await child.exited;
133
+ running = false;
119
134
  if (code !== DEV_RESTART_EXIT_CODE || stopping) return code;
120
135
  }
121
136
  } finally {
122
137
  process.off('SIGINT', onInt);
123
138
  process.off('SIGTERM', onTerm);
139
+ process.off('exit', onExit);
124
140
  }
125
141
  }
126
142
 
@@ -156,23 +172,36 @@ export interface ChildRestart {
156
172
  * Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
157
173
  * starts core's drain — the hold then releases the lock, the port and the embedded database — and
158
174
  * exits `DEV_RESTART_EXIT_CODE` once released. Anything else keeps the finding on `/_x`.
175
+ *
176
+ * A child its supervisor named (`DEV_SUPERVISOR_PID_ENV`) also watches for that supervisor's death
177
+ * and its app root's, and stops on either — exit 1 for a root that is gone, which the supervisor,
178
+ * if it is still there, answers by stopping rather than respawning into nothing.
159
179
  */
160
180
  export function childRestart(
161
181
  root: string,
162
182
  env: Readonly<Record<string, string | undefined>>,
183
+ watch: typeof startDevChildWatch = startDevChildWatch,
163
184
  ): ChildRestart {
164
185
  if (env[DEV_CHILD_ENV] !== '1') return { options: {}, exit: () => undefined };
165
- let restarting = false;
186
+ let leaving: 'restart' | DevChildGone | undefined;
187
+ const watched =
188
+ supervisorPid(env) === undefined
189
+ ? undefined
190
+ : watch(root, env, (why) => {
191
+ leaving ??= why;
192
+ });
166
193
  return {
167
194
  options: {
168
195
  onRestart: (pins) => {
169
196
  process.stderr.write(`${msg('cli.dev.restart', { reason: restartReason(root, pins) })}\n`);
170
- restarting = true;
197
+ leaving ??= 'restart';
198
+ watched?.stopping(DEV_RESTART_EXIT_CODE);
171
199
  void drain('restart');
172
200
  },
173
201
  },
174
202
  exit: () => {
175
- if (restarting) process.exit(DEV_RESTART_EXIT_CODE);
203
+ if (leaving === 'restart') process.exit(DEV_RESTART_EXIT_CODE);
204
+ if (leaving === 'root') process.exit(1);
176
205
  },
177
206
  };
178
207
  }
@@ -204,6 +204,7 @@ export const CLI_OWNED_ERROR_CODES = [
204
204
  'X_VERIFY_MERGE_INPUT',
205
205
  'X_SITEMAP_EXTRA_INVALID',
206
206
  'X_DEV_RESTART_REQUIRED',
207
+ 'X_DEV_ROOT_GONE',
207
208
  ] as const;
208
209
 
209
210
  /**
@@ -358,6 +359,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
358
359
  X_SITEMAP_EXTRA_INVALID: 'a seo.sitemap.extra path names no public page',
359
360
  X_DEV_RESTART_REQUIRED:
360
361
  'a save reached a module that defines a primitive; only a new process serves it',
362
+ X_DEV_ROOT_GONE: 'the app root x dev serves was deleted or moved',
361
363
  };
362
364
 
363
365
  // One unconditional call, so a second package claiming one of the CLI's codes throws
package/src/index.ts CHANGED
@@ -107,7 +107,7 @@ export { ICON_BASE_PATH, ICON_SOURCE } from './icon-assets';
107
107
  // `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
108
108
  // `islandBundle`, `writeIslands`, `ISLAND_BASE_PATH` and `ISLAND_GLOB` stay internal: they are
109
109
  // `x build`'s and `x dev`'s wiring, and every name here is a semver promise forever.
110
- export type { IslandChunk } from './island-bundle';
110
+ export type { IslandChunk, SharedChunk } from './island-bundle';
111
111
  export { buildIslands } from './island-bundle';
112
112
  export { JOB_STATES } from './jobs-report';
113
113
  export { renderJobTable } from './jobs-table';