@ultimat3/cli 19.2.0 → 19.3.1

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.
Files changed (47) hide show
  1. package/CLAUDE.md +108 -6
  2. package/package.json +29 -29
  3. package/src/app-boundaries.ts +11 -2
  4. package/src/app-load.ts +5 -1
  5. package/src/budgets.ts +17 -6
  6. package/src/cmd-dev.ts +29 -39
  7. package/src/cmd-doctor.ts +61 -23
  8. package/src/cmd-generate.ts +5 -2
  9. package/src/cmd-i18n.ts +10 -3
  10. package/src/cmd-jobs.ts +56 -10
  11. package/src/cmd-test.ts +15 -10
  12. package/src/db-seed.ts +2 -1
  13. package/src/dev-queue.ts +16 -2
  14. package/src/dev-reload.ts +46 -0
  15. package/src/dev-runtime.ts +4 -1
  16. package/src/dev-sync.ts +11 -3
  17. package/src/dev-watch-tree.ts +226 -0
  18. package/src/dev-watch.ts +59 -37
  19. package/src/doctor-offline.ts +122 -0
  20. package/src/error-catalog.ts +4 -5
  21. package/src/fix-command.ts +40 -1
  22. package/src/fix-path.ts +10 -11
  23. package/src/flag-number.ts +15 -0
  24. package/src/generate-kinds.ts +54 -4
  25. package/src/generate-write.ts +25 -2
  26. package/src/gitignore.ts +145 -0
  27. package/src/hold.ts +50 -17
  28. package/src/index.ts +1 -1
  29. package/src/island-bundle.ts +2 -1
  30. package/src/island-states-load.ts +2 -1
  31. package/src/jobs-driver.ts +4 -1
  32. package/src/mcp-host.ts +18 -9
  33. package/src/parse.ts +17 -0
  34. package/src/path-segments.ts +14 -0
  35. package/src/prerender.ts +46 -20
  36. package/src/retry-memo.ts +37 -0
  37. package/src/serve.ts +5 -1
  38. package/src/source-files.ts +3 -1
  39. package/src/sw-artifacts.ts +71 -7
  40. package/src/templates/admin-page.ts +49 -1
  41. package/src/templates/scaffold-container.ts +12 -0
  42. package/src/templates/scaffold-repo.ts +7 -2
  43. package/src/test-passes.ts +79 -0
  44. package/src/test-shards.ts +110 -36
  45. package/src/verify-checks.ts +6 -6
  46. package/src/verify-step.ts +4 -4
  47. package/src/verify-tests.ts +14 -2
@@ -8,7 +8,7 @@ import { appManifest, writeAppManifest } from './app-manifest';
8
8
  import { requireAppRoot } from './app-root';
9
9
  import type { CliCommand, CommandContext } from './command';
10
10
  import { generate } from './generate-files';
11
- import { GENERATORS, readKind, readName, readSurface } from './generate-kinds';
11
+ import { GENERATORS, readKind, readName, readPermission, readSurface } from './generate-kinds';
12
12
  import { containedPath, writeFiles } from './generate-write';
13
13
  import { resolveCatalogModule } from './i18n-audit';
14
14
  import { syncI18nIndex } from './i18n-index';
@@ -63,7 +63,10 @@ export const generateCommand: CliCommand = {
63
63
  const surface = readSurface(flagString(ctx.args, 'surface'), kind, name);
64
64
  const locales = resolveLocales(flagList(ctx.args, 'locales'));
65
65
  const at = flagString(ctx.args, 'at');
66
- const permission = flagString(ctx.args, 'permission');
66
+ // Read with the two above, and refused here for their reason: the value is spliced into the
67
+ // emitted source three times, and a value that is not a `<resource>:<verb>` is a page the app
68
+ // cannot compile — found after the files are on disk, which is the worst place to find it.
69
+ const permission = readPermission(flagString(ctx.args, 'permission'), kind);
67
70
  // Read before a file is planned, like the flags above: which module a generated component
68
71
  // imports `useT()` from is a fact about THIS app, and `generate` is a pure function.
69
72
  const catalogModule = await resolveCatalogModule(root);
package/src/cmd-i18n.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // would, and `node:path` because Bun exposes no path API to build what either of them takes.
9
9
  import { type FileHandle, mkdir, open } from 'node:fs/promises';
10
10
  import { dirname, join } from 'node:path';
11
+ import { stringField } from '@ultimat3/core';
11
12
  import type { Catalog } from '@ultimat3/i18n';
12
13
  import { auditCatalogs, catalogKeys } from '@ultimat3/i18n';
13
14
  import { loadApp } from './app-load';
@@ -40,9 +41,15 @@ export const I18N_SUBCOMMANDS = ['check', 'add', 'sync'] as const;
40
41
  /** `ExtractReport` is plain JSON by construction — same idiom as `cmd-registries.ts`'s `asJson`. */
41
42
  const asJson = (value: object): Record<string, JsonValue> => value as Record<string, JsonValue>;
42
43
 
43
- /** `open`'s failure when the file is already there — the one errno this command translates. */
44
- const isAlreadyExists = (error: unknown): boolean =>
45
- typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST';
44
+ /**
45
+ * `open`'s failure when the file is already there — the one errno this command translates.
46
+ *
47
+ * `stringField`, never `'code' in error && error.code`: `in` narrows for the COMPILER and promises
48
+ * the runtime nothing, so the read still happens on a value this process did not build and a
49
+ * throwing getter takes the command down one line after the guard written to make it safe. The
50
+ * same repair `dev-lock.ts` took for the cast spelling of the identical read.
51
+ */
52
+ const isAlreadyExists = (error: unknown): boolean => stringField(error, 'code') === 'EEXIST';
46
53
 
47
54
  /**
48
55
  * The exclusive half of the create: `wx` fails rather than truncates, so an existing catalog is
package/src/cmd-jobs.ts CHANGED
@@ -5,10 +5,11 @@
5
5
  // `jobs-table.ts`, and getting hold of the queue at all is `jobs-driver.ts` — shared with `x db`.
6
6
 
7
7
  import type { JobDriver } from '@ultimat3/jobs';
8
- import { cancelJob, createMemoryDriver, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
8
+ import { cancelJob, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
9
9
  import { requireAppRoot } from './app-root';
10
10
  import type { CliCommand, CommandContext } from './command';
11
11
  import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
12
+ import type { DrainOutcome } from './jobs-drain';
12
13
  import { drainJobs } from './jobs-drain';
13
14
  import { withJobDriver } from './jobs-driver';
14
15
  import {
@@ -28,7 +29,32 @@ import { flagBool, flagString } from './parse';
28
29
 
29
30
  export const JOBS_SUBCOMMANDS = ['ls', 'show', 'retry', 'cancel', 'drain'] as const;
30
31
 
31
- const DRAIN_TARGETS = ['memory', 'redis', 'nats'] as const;
32
+ /**
33
+ * The drivers a drain may move work ONTO — every one of them durable, and that is the whole rule.
34
+ * Closed, and read three ways: the flag summary, the refusal, and the `memory` case below.
35
+ */
36
+ export const DRAIN_TARGETS = ['redis', 'nats'] as const;
37
+
38
+ /**
39
+ * `memory` was on that list until 2026-09 and could not be: `createMemoryDriver()` is a `Map` in
40
+ * THIS process, so `x jobs drain --to memory` enqueued each job into it and then `ack`ed the
41
+ * durable row off the source. Reproduced against two real drivers — source ready 1 -> 0, target
42
+ * ready 1, `ok: true` — and the target dies with the command. `wiki/CLI-Reference.md` said the
43
+ * crash window "duplicates a job … instead of losing it"; this target lost every one of them.
44
+ *
45
+ * Refused by NAME rather than folded into the unknown-value message, for `cmd-deploy.ts`'s
46
+ * `readMethod` reason: `--to memory` is a spelling that used to work, so a reader who types it is
47
+ * owed the fact that it moved work into a process that is about to exit, not a list of words.
48
+ */
49
+ function refuseMemoryTarget(): never {
50
+ throw new BadFlagError({
51
+ flag: 'to',
52
+ command: 'jobs',
53
+ reason:
54
+ 'memory is a Map inside this process — the drain would ack every durable row and lose the copy when the command exits',
55
+ fix: 'x jobs drain --to redis --json # or --to nats; --dry-run reports the plan and moves nothing',
56
+ });
57
+ }
32
58
 
33
59
  function requireIdPositional(ctx: CommandContext, sub: string): string {
34
60
  const id = ctx.args.positionals[0];
@@ -57,10 +83,13 @@ function requireEnvUrl(env: CommandContext['env'], name: string, target: string)
57
83
 
58
84
  /**
59
85
  * `redis`/`nats` are honest `X_NOT_IMPLEMENTED` stubs in `@ultimat3/jobs` — building one here is
60
- * fine even though every `enqueue` on it will fail; `drainJobs` reports that per record.
86
+ * fine even though every `enqueue` on it will fail; `drainJobs` reports that per record. What is
87
+ * NOT fine is a target that accepts every enqueue and then vanishes, which is why `memory` is
88
+ * refused first and by name rather than falling into the closed-set message below.
61
89
  * Exported so a test can drive the `--to`/env-var validation without a driver or a boot.
62
90
  */
63
91
  export function buildDrainTarget(to: string | undefined, env: CommandContext['env']): JobDriver {
92
+ if (to === 'memory') refuseMemoryTarget();
64
93
  if (to === undefined || !(DRAIN_TARGETS as readonly string[]).includes(to)) {
65
94
  throw new BadFlagError({
66
95
  flag: 'to',
@@ -68,7 +97,6 @@ export function buildDrainTarget(to: string | undefined, env: CommandContext['en
68
97
  reason: `expects one of: ${DRAIN_TARGETS.join(', ')}`,
69
98
  });
70
99
  }
71
- if (to === 'memory') return createMemoryDriver();
72
100
  if (to === 'redis') return createRedisDriver({ url: requireEnvUrl(env, 'REDIS_URL', 'redis') });
73
101
  return createNatsDriver({ servers: [requireEnvUrl(env, 'NATS_URL', 'nats')] });
74
102
  }
@@ -174,11 +202,13 @@ async function runCancel(driver: JobDriver, ctx: CommandContext): Promise<Comman
174
202
  * A skipped candidate is not an error — a job whose `runAt` has not arrived is unclaimable by
175
203
  * design — so it carries no `X_*` finding. It still fails the command: `x jobs drain` is run to
176
204
  * empty a driver, and a partial move that exited 0 would read as "the queue is clear".
205
+ *
206
+ * Exported, and separate from the flag reading above it, because every target the flag now accepts
207
+ * needs a server: a test can produce a real outcome from two drivers and render THAT, where
208
+ * driving the whole command would need a redis or a nats to move anything at all.
177
209
  */
178
- async function runDrain(driver: JobDriver, ctx: CommandContext): Promise<CommandResult> {
179
- const target = buildDrainTarget(flagString(ctx.args, 'to'), ctx.env);
180
- const dryRun = flagBool(ctx.args, 'dry-run');
181
- const outcome = await drainJobs(driver, target, dryRun);
210
+ export function drainResult(outcome: DrainOutcome): CommandResult {
211
+ const dryRun = outcome.dryRun;
182
212
  const findings = outcome.failures.map((failure) => failure.finding);
183
213
  const lines: string[] = [];
184
214
  if (outcome.skipped.length > 0) {
@@ -212,6 +242,15 @@ async function runDrain(driver: JobDriver, ctx: CommandContext): Promise<Command
212
242
  };
213
243
  }
214
244
 
245
+ /** The move, then the render. The target is built ABOVE `withJobDriver` — see `run` below. */
246
+ async function runDrain(
247
+ driver: JobDriver,
248
+ target: JobDriver,
249
+ ctx: CommandContext,
250
+ ): Promise<CommandResult> {
251
+ return drainResult(await drainJobs(driver, target, flagBool(ctx.args, 'dry-run')));
252
+ }
253
+
215
254
  export const jobsCommand: CliCommand = {
216
255
  spec: {
217
256
  name: 'jobs',
@@ -245,7 +284,7 @@ export const jobsCommand: CliCommand = {
245
284
  {
246
285
  name: 'to',
247
286
  type: 'string',
248
- summary: 'drain: target driver — memory, redis, nats',
287
+ summary: `drain: target driver — ${DRAIN_TARGETS.join(', ')}`,
249
288
  subcommands: ['drain'],
250
289
  },
251
290
  {
@@ -259,11 +298,18 @@ export const jobsCommand: CliCommand = {
259
298
  async run(ctx: CommandContext): Promise<CommandResult> {
260
299
  const root = requireAppRoot('jobs', ctx.cwd).dir;
261
300
  const sub = ctx.args.subcommand ?? 'ls';
301
+ // BEFORE `withJobDriver`, which boots the SOURCE queue and pings it. `--to` is a flag, so
302
+ // whether it names a durable driver is answerable with no server at all — and reading it
303
+ // inside meant `x jobs drain --to memory` on a box whose database is down reported the boot
304
+ // failure instead of `X_CLI_BAD_FLAG`, i.e. the operator repaired Postgres to be told the
305
+ // word they typed was refused by name. It also opens a connection to a target the command
306
+ // then refuses, which is a socket nothing closes.
307
+ const target = sub === 'drain' ? buildDrainTarget(flagString(ctx.args, 'to'), ctx.env) : null;
262
308
  return withJobDriver(root, ctx, (driver) => {
263
309
  if (sub === 'show') return runShow(driver, ctx);
264
310
  if (sub === 'retry') return runRetry(driver, ctx);
265
311
  if (sub === 'cancel') return runCancel(driver, ctx);
266
- if (sub === 'drain') return runDrain(driver, ctx);
312
+ if (target !== null) return runDrain(driver, target, ctx);
267
313
  return runLs(driver, ctx);
268
314
  });
269
315
  },
package/src/cmd-test.ts CHANGED
@@ -104,8 +104,12 @@ export const testCommand: CliCommand = {
104
104
  name: 'test',
105
105
  summary:
106
106
  'run one test type — or the whole suite — across N workers, one isolated database per worker',
107
- usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json]`,
107
+ usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json] [-- <bun test flags>]`,
108
108
  positionalChoices: TEST_TYPES,
109
+ // The one command that hands a tail to another tool — `bun test` — and the reason
110
+ // `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
111
+ // dropped both, so a run that measured no coverage reported exactly what a coverage run does.
112
+ passthrough: true,
109
113
  flags: [
110
114
  {
111
115
  name: 'workers',
@@ -173,15 +177,13 @@ export const testCommand: CliCommand = {
173
177
  }
174
178
  const files = sample === undefined ? selected : sampleFiles(selected, sample);
175
179
  const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
176
- // A serial type is serial HERE TOO, `As of 2026-08-27`. `verify-tests.ts` routes `live` and
177
- // `e2e` through `runSerial` and this command never read the same list, so `x verify` ran one
178
- // process over the very files `x test live --workers 8` ran eight over — two answers to one
179
- // question, which is axiom 1, and the dangerous one is the command a human types while
180
- // debugging. What makes them serial is not a preference: a logical replication slot is named
181
- // at the Postgres CLUSTER level, so a per-worker database does not isolate it and two workers
182
- // race `pg_create_logical_replication_slot`; `e2e` shares one built `dist/` and one browser
183
- // profile. Neither is visible without a real `TEST_DATABASE_URL`, which is why the split
184
- // measured green for as long as it did.
180
+ // The width a `--worker` index is judged against, and nothing else: which files really run one
181
+ // at a time is `test-passes.ts`, off the FILES rather than off the positional. This line read
182
+ // the positional alone until 2026-09 and the comment here claimed it made a serial type serial
183
+ // — true for `x test live`, false for the bare `x test --workers 8` that selects every type,
184
+ // which ran the same `live` and `e2e` files eight at a time. It stays because a `--worker` run
185
+ // is one process per CI JOB: sharding a serial type across N of them is the cluster-wide race
186
+ // in a second disguise, and refusing the index is where that is caught.
185
187
  const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
186
188
  const workers = Math.max(1, Math.min(requested, ceiling));
187
189
  const only = readIndex(ctx.args, 'worker', 0);
@@ -208,6 +210,9 @@ export const testCommand: CliCommand = {
208
210
  // corpus, so its shard 2 is a different shard 2 — reproducing nothing, which is the one
209
211
  // thing `reproduceFor` exists to prevent.
210
212
  ...(scope === undefined ? {} : { affected: scope.selection }),
213
+ // The fifth input to the split, and the one a rerun most obviously needs: `--coverage`
214
+ // changes what a run measures, and the reproduce line carries it back out.
215
+ ...(ctx.args.passthrough.length === 0 ? {} : { passthrough: ctx.args.passthrough }),
211
216
  });
212
217
  return scope === undefined ? result : withScope(result, scope);
213
218
  },
package/src/db-seed.ts CHANGED
@@ -15,6 +15,7 @@ import { isSeed, SEED_TIERS, seedTiersFor } from '@ultimat3/entity';
15
15
  import { BadFlagError } from './errors';
16
16
  import type { Finding, JsonValue } from './output';
17
17
  import { findingFrom } from './output';
18
+ import { hasPathSegment } from './path-segments';
18
19
  import { renderTable } from './table';
19
20
 
20
21
  /**
@@ -101,7 +102,7 @@ export async function discoverSeeds(root: string): Promise<SeedDiscovery> {
101
102
  const seen = new Set<string>();
102
103
  for (const pattern of SEED_GLOBS) {
103
104
  for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
104
- if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
105
+ if (hasPathSegment(absolute, 'node_modules') || absolute.includes('.test.')) continue;
105
106
  if (seen.has(absolute)) continue;
106
107
  seen.add(absolute);
107
108
  const file = relative(root, absolute).split(sep).join('/');
package/src/dev-queue.ts CHANGED
@@ -78,8 +78,16 @@ export interface RunningQueue {
78
78
  * Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
79
79
  * it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
80
80
  * so `DATABASE_REPLICA_URL` was read by no booted process at all.
81
+ *
82
+ * `env` is REQUIRED, and that is the repair: it defaulted to `process.env` and `startQueue` passed
83
+ * nothing, so the standby was decided from the process while the middleware that opens the
84
+ * `withReplicaReads` scope was decided from the boot's own `options.env` (`cmd-dev.ts`,
85
+ * `serve.ts`). Two sources for one question answer differently the moment a boot is handed an
86
+ * environment it did not inherit — a routed client with no scope, or a scope with no standby, and
87
+ * neither reports anything. A default here is what let the caller forget; the type is what stops
88
+ * the next one. Exported for the test that proves which environment decides.
81
89
  */
82
- function startDb(services: DevServices, env: ReplicaEnv = process.env): StartedDb {
90
+ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
83
91
  const binding = services.db;
84
92
  const client =
85
93
  binding.mode === 'embedded'
@@ -218,8 +226,14 @@ async function releaseQueue(
218
226
  export async function startQueue(
219
227
  services: DevServices,
220
228
  overrides?: RuntimeOverrides,
229
+ /**
230
+ * The boot's own environment — `x dev`'s, the container role's, the CLI command's `ctx.env`.
231
+ * `process.env` is the default for a caller that has no other answer, and it is the ONLY place
232
+ * this file reads it: see `startDb` for what two readers of one question cost.
233
+ */
234
+ env: ReplicaEnv = process.env,
221
235
  ): Promise<RunningQueue> {
222
- const { client: db, replica } = startDb(services);
236
+ const { client: db, replica } = startDb(services, env);
223
237
  try {
224
238
  // Pay the Postgres boot here, so the first request is not the slow one and a broken database
225
239
  // fails at boot rather than on some later query.
@@ -0,0 +1,46 @@
1
+ // One rebuild at a time. A reload is a full `appManifest()` plus a `buildIslands()` over every
2
+ // island, and the watcher had no in-flight guard: a 45ms drip of writes — a slow `git checkout`, a
3
+ // formatter walking the tree, `x db gen` — started one per file, 40 for 40 files, each assigning
4
+ // the same two state slots in COMPLETION order. So a slower earlier rebuild could land on top of a
5
+ // newer one, and the dev server then served a manifest built from source that had already changed.
6
+
7
+ /** What a tick does while a rebuild is already running: nothing, except become the next one. */
8
+ export type ReloadTrigger = (file: string) => void;
9
+
10
+ /**
11
+ * Serialise `run`, keeping only the LAST tick that arrived while it was busy. N ticks during one
12
+ * rebuild are exactly one more rebuild, for the newest file — never N queued ones, and never a
13
+ * dropped tick, which would leave the process serving the state before the author's last save.
14
+ *
15
+ * `onError` defaults to swallowing, because the caller that cares reports its own failure as a
16
+ * finding; a rejection escaping here would be an unhandled rejection that takes `x dev` down.
17
+ */
18
+ export function coalesceReloads(
19
+ run: (file: string) => Promise<void> | void,
20
+ onError: (error: unknown, file: string) => void = () => undefined,
21
+ ): ReloadTrigger {
22
+ let running = false;
23
+ let pending: string | undefined;
24
+
25
+ const start = (file: string): void => {
26
+ running = true;
27
+ // `Promise.resolve().then` rather than a bare call: a SYNCHRONOUS throw from `run` would
28
+ // otherwise escape the fs callback that triggered it, where nothing is listening.
29
+ void (async () => await run(file))()
30
+ .catch((error: unknown) => onError(error, file))
31
+ .finally(() => {
32
+ running = false;
33
+ const next = pending;
34
+ pending = undefined;
35
+ if (next !== undefined) start(next);
36
+ });
37
+ };
38
+
39
+ return (file: string): void => {
40
+ if (running) {
41
+ pending = file;
42
+ return;
43
+ }
44
+ start(file);
45
+ };
46
+ }
@@ -299,7 +299,10 @@ export async function startServices(
299
299
  // `@ultimat3/realtime`'s decision, and it is the same call a `ROLE=sync` container makes, so this
300
300
  // process cannot resolve the bus differently from the container it stands in for.
301
301
  const bus: TransportSelection = selectTransport(env);
302
- const queue = await startQueue(services, overrides);
302
+ // `env`, not the ambient one: this function is HANDED the boot's environment and every other
303
+ // reader here already uses it, so a queue that asked `process.env` would decide the standby from
304
+ // a different answer than the middleware that routes to it.
305
+ const queue = await startQueue(services, overrides, env);
303
306
  const { db, jobs, outbox, events } = queue;
304
307
  // The same executor the jobs driver, the outbox, the event bus and the idempotency store run
305
308
  // on — one pool, one `Bun.sql` that does NOT satisfy `PgExecutor` (`Bun.sql.query` is
package/src/dev-sync.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  SocketRegistry,
16
16
  } from '@ultimat3/realtime/server';
17
17
  import type { StartRolesOptions } from './dev-roles';
18
- import { neighbouringPort, PORT_RANGE } from './flag-number';
18
+ import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
19
19
  import { portFree } from './port-probe';
20
20
  import { syncAuthenticator } from './sync-authenticator';
21
21
  import { DEV_BINDING } from './web-binding';
@@ -53,7 +53,11 @@ class SyncPortInUseError extends UltimateError {
53
53
  super({
54
54
  code: 'X_PORT_IN_USE',
55
55
  cause: `the sync role binds PORT + 1, so \`x dev --port ${input.webPort}\` needs port ${input.port} and something is already listening on it`,
56
- fix: `x dev --port ${neighbouringPort(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
56
+ // `portPairAfter`, never `neighbouringPort`: `x dev` binds a PAIR, so the neighbour of the
57
+ // web port IS the sync port this refusal is about — the fix said `x dev --port 4000` for a
58
+ // run that had just died on 4000, and a test named "its fix is a command that ends the
59
+ // failure" pinned it.
60
+ fix: `x dev --port ${portPairAfter(input.webPort)} # or free port ${input.port}: lsof -nP -iTCP:${input.port} -sTCP:LISTEN`,
57
61
  meta: { port: input.port, webPort: input.webPort },
58
62
  });
59
63
  }
@@ -183,7 +187,11 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
183
187
  // so the one socket that streams live database patches was the one socket on every
184
188
  // interface — and `WebBinding`'s own docstring is about not serving a laptop's app to a café.
185
189
  const binding = options.http ?? DEV_BINDING;
186
- const listener = listenSyncNode(node, { port, hostname: binding.hostname });
190
+ // No drain grace: there is one node here and it is the one going away. Its clients reconnect
191
+ // to it when `x dev` is back, and a grace that kept their patches flowing meanwhile was five
192
+ // seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a reconnect frame
193
+ // whose target does not exist yet.
194
+ const listener = listenSyncNode(node, { port, hostname: binding.hostname, drainGraceMs: 0 });
187
195
  return {
188
196
  url: listener.url,
189
197
  registry,
@@ -0,0 +1,226 @@
1
+ // The app root watched one directory at a time, because the watch SET is a registration decision
2
+ // and not a filter. `watch(root, { recursive: true })` takes an inotify descriptor per directory
3
+ // in the tree — including every directory the dev loop then discards events from — so `.git/`,
4
+ // `node_modules/` and `.x/` (which this very process writes to, continuously) each cost a
5
+ // descriptor, a kernel queue entry and a JS callback per write, against a per-user descriptor
6
+ // budget of 8192 on many distributions. Bun 1.4.0's `fs.watch` takes no ignore option, so the
7
+ // only place the answer can be given early is at registration.
8
+
9
+ // why: Bun exposes no filesystem watcher, no directory listing and no synchronous stat —
10
+ // `Bun.file().exists()` is async and answers false for a DIRECTORY, which is the one thing this
11
+ // module has to decide. Delete each when Bun ships an equivalent.
12
+ import type { Dirent } from 'node:fs';
13
+ import { readdirSync, statSync, watch } from 'node:fs';
14
+ // why: Bun exposes no path-join primitive.
15
+ import { join } from 'node:path';
16
+ import { finiteCount, logger } from '@ultimat3/core';
17
+ import type { DevIgnore } from './dev-watch';
18
+ import { devIgnore } from './dev-watch';
19
+ import { pathSegments } from './path-segments';
20
+
21
+ /** What `node:fs`'s watcher hands a listener — `filename` is optional in fact, not only in type. */
22
+ export type WatchListener = (event: string, filename: string | Buffer | null | undefined) => void;
23
+
24
+ /** The one thing this module needs of a watcher, so a test can stand one up in four lines. */
25
+ export interface DirectoryWatcher {
26
+ close(): void;
27
+ }
28
+
29
+ export type WatchDirectory = (directory: string, listener: WatchListener) => DirectoryWatcher;
30
+
31
+ export interface WatchTreeOptions {
32
+ readonly root: string;
33
+ /** A source change, app-root-relative and POSIX-separated. Debounced. */
34
+ readonly onChange: (file: string) => void;
35
+ /** Trailing debounce. A save touching five files is one reload, not five. Default 30ms. */
36
+ readonly debounceMs?: number;
37
+ /** Test seam: the default is `node:fs`'s `watch(dir, { recursive: false })`. */
38
+ readonly watchDirectory?: WatchDirectory;
39
+ }
40
+
41
+ export interface WatchTree {
42
+ /** Directories holding a descriptor right now, app-root-relative; `''` is the app root. */
43
+ directories(): readonly string[];
44
+ close(): void;
45
+ }
46
+
47
+ const DEFAULT_DEBOUNCE_MS = 30;
48
+
49
+ /**
50
+ * Every directory under `root` an event could be a source change in, `''` first. Ignored
51
+ * directories are not descended, so a `node_modules` tree costs one `readdir` and nothing else.
52
+ * A symlinked directory is deliberately not followed: `Dirent.isDirectory()` is false for one, and
53
+ * a workspace symlink pointing back into the checkout would otherwise be walked twice.
54
+ */
55
+ export function admittedDirectories(root: string, ignore: DevIgnore): readonly string[] {
56
+ const admitted: string[] = [''];
57
+ const pending: string[] = [''];
58
+ for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
59
+ for (const entry of childrenOf(join(root, next))) {
60
+ if (!entry.isDirectory()) continue;
61
+ const child = next === '' ? entry.name : `${next}/${entry.name}`;
62
+ if (ignore.ignores(child, true)) continue;
63
+ admitted.push(child);
64
+ pending.push(child);
65
+ }
66
+ }
67
+ return admitted.sort();
68
+ }
69
+
70
+ /**
71
+ * One directory's entries, or none. A directory removed between the listing above and this read, or
72
+ * one this user may not open, is neither a source change nor a finding anyone can act on.
73
+ */
74
+ function childrenOf(path: string): readonly Dirent[] {
75
+ try {
76
+ return readdirSync(path, { withFileTypes: true });
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ const nodeWatch: WatchDirectory = (directory, listener) =>
83
+ watch(directory, { recursive: false }, listener);
84
+
85
+ /** Whether the path is a directory right now — the question a `rename` event does not answer. */
86
+ function isDirectoryAt(path: string): boolean {
87
+ try {
88
+ return statSync(path).isDirectory();
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
94
+ export function watchTree(options: WatchTreeOptions): WatchTree {
95
+ const { root, onChange } = options;
96
+ // Screened before a single descriptor is taken. `??` guards NULLISH and `NaN` is not nullish, so
97
+ // an unparsed value walks past the default into `setTimeout(fn, NaN)`, which coerces to **0**:
98
+ // the debounce reads as installed and every keystroke runs a full `appManifest()` plus
99
+ // `buildIslands()`. `0` is admitted — "rebuild on the next tick" is a decision — and `Infinity`
100
+ // is not, because a reload that never fires is the same defect facing the other way.
101
+ const debounceMs = finiteCount(
102
+ 'watchTree',
103
+ 'debounceMs',
104
+ options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
105
+ );
106
+ const open = new WatchDirectoryMap(options.watchDirectory ?? nodeWatch, root);
107
+ let ignore = devIgnore(root);
108
+ let timer: ReturnType<typeof setTimeout> | undefined;
109
+ let last = '';
110
+ let warned = false;
111
+
112
+ const schedule = (file: string): void => {
113
+ last = file;
114
+ if (timer !== undefined) clearTimeout(timer);
115
+ timer = setTimeout(() => onChange(last), debounceMs);
116
+ };
117
+
118
+ const listen = (directory: string): void => {
119
+ open.add(directory, (event, filename) => {
120
+ if (typeof filename !== 'string' && !(filename instanceof Buffer)) {
121
+ // Bun's watcher delivers no filename when the WATCHED directory itself moves or is removed
122
+ // — `mv myapp myapp2`, a re-clone, a volume remount. Once, because the same rename can
123
+ // arrive on every descriptor at the same instant.
124
+ if (!warned) logger.warn('dev.watch.unnamed_event', { directory: directory || '.' });
125
+ warned = true;
126
+ return;
127
+ }
128
+ const name = pathSegments(filename.toString()).join('/');
129
+ const file = directory === '' ? name : `${directory}/${name}`;
130
+ // Asked of the DISK, once, because every trailing-slash rule in a `.gitignore` turns on it
131
+ // and a `rename` says only that something moved. One stat against a whole rebuild.
132
+ const isDirectory = open.has(file) || isDirectoryAt(join(root, file));
133
+ if (event === 'rename') follow(file, isDirectory);
134
+ if (file === '.gitignore') {
135
+ // The ignore set is the app's own, so an edit to it changes which directories are watched
136
+ // at all. Not a source change: rebuilding the manifest for it would be a reload the author
137
+ // did not ask for on the one file whose whole job is saying what to leave alone.
138
+ ignore = devIgnore(root);
139
+ reconcile();
140
+ return;
141
+ }
142
+ if (ignore.ignores(file, isDirectory)) return;
143
+ schedule(file);
144
+ });
145
+ };
146
+
147
+ /** A `rename` created or removed something: keep the descriptor set honest either way. */
148
+ const follow = (file: string, isDirectory: boolean): void => {
149
+ if (isDirectory && isDirectoryAt(join(root, file))) {
150
+ if (ignore.ignores(file, true)) return;
151
+ if (!open.has(file)) for (const found of subtree(file)) listen(found);
152
+ return;
153
+ }
154
+ if (open.has(file)) open.remove(file);
155
+ };
156
+
157
+ /** The admitted directories at or under `directory`, discovered rather than assumed. */
158
+ const subtree = (directory: string): readonly string[] =>
159
+ admittedDirectories(join(root, directory), ignore).map((found) =>
160
+ found === '' ? directory : `${directory}/${found}`,
161
+ );
162
+
163
+ /** The whole set, re-derived: a directory the author just ignored gives its descriptor back. */
164
+ const reconcile = (): void => {
165
+ const admitted = new Set(admittedDirectories(root, ignore));
166
+ for (const held of open.directories()) if (!admitted.has(held)) open.remove(held);
167
+ for (const directory of admitted) if (!open.has(directory)) listen(directory);
168
+ };
169
+
170
+ for (const directory of admittedDirectories(root, ignore)) listen(directory);
171
+
172
+ return {
173
+ directories: () => open.directories(),
174
+ close(): void {
175
+ if (timer !== undefined) clearTimeout(timer);
176
+ open.closeAll();
177
+ },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The descriptors this watcher holds, keyed by app-root-relative directory. Its own type because
183
+ * removing one means removing everything under it: a deleted directory takes its children's
184
+ * descriptors with it, and a `Map` iterated by the caller would leak every one of them.
185
+ */
186
+ class WatchDirectoryMap {
187
+ readonly #watchers = new Map<string, DirectoryWatcher>();
188
+ readonly #watch: WatchDirectory;
189
+ readonly #root: string;
190
+
191
+ constructor(watchDirectory: WatchDirectory, root: string) {
192
+ this.#watch = watchDirectory;
193
+ this.#root = root;
194
+ }
195
+
196
+ add(directory: string, listener: WatchListener): void {
197
+ if (this.#watchers.has(directory)) return;
198
+ try {
199
+ this.#watchers.set(directory, this.#watch(join(this.#root, directory), listener));
200
+ } catch {
201
+ // A directory that vanished between the walk and the registration. The parent's own
202
+ // descriptor still reports anything that reappears there.
203
+ }
204
+ }
205
+
206
+ has(directory: string): boolean {
207
+ return this.#watchers.has(directory);
208
+ }
209
+
210
+ directories(): readonly string[] {
211
+ return [...this.#watchers.keys()].sort();
212
+ }
213
+
214
+ remove(directory: string): void {
215
+ for (const held of this.#watchers.keys()) {
216
+ if (held !== directory && !held.startsWith(`${directory}/`)) continue;
217
+ this.#watchers.get(held)?.close();
218
+ this.#watchers.delete(held);
219
+ }
220
+ }
221
+
222
+ closeAll(): void {
223
+ for (const watcher of this.#watchers.values()) watcher.close();
224
+ this.#watchers.clear();
225
+ }
226
+ }