@ultimat3/cli 9.0.0 → 11.0.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.
Files changed (78) hide show
  1. package/CLAUDE.md +71 -0
  2. package/package.json +28 -26
  3. package/src/affected.ts +0 -3
  4. package/src/app-boundaries.ts +4 -5
  5. package/src/app-env.ts +7 -2
  6. package/src/browser-launcher.ts +0 -2
  7. package/src/budgets.ts +10 -3
  8. package/src/cmd-build.ts +2 -2
  9. package/src/cmd-db-backfill.ts +14 -1
  10. package/src/cmd-db-branch.ts +2 -2
  11. package/src/cmd-deploy.ts +14 -3
  12. package/src/cmd-dev.ts +3 -0
  13. package/src/cmd-docs.ts +2 -1
  14. package/src/cmd-doctor.ts +3 -5
  15. package/src/cmd-env.ts +2 -2
  16. package/src/cmd-fix.ts +2 -4
  17. package/src/cmd-new.ts +36 -6
  18. package/src/cmd-shot.ts +22 -2
  19. package/src/command.ts +12 -0
  20. package/src/db-finding.ts +2 -2
  21. package/src/db-seed.ts +0 -3
  22. package/src/dev-assets.ts +6 -0
  23. package/src/dev-cache.ts +12 -5
  24. package/src/dev-hooks.ts +8 -0
  25. package/src/dev-lock.ts +8 -7
  26. package/src/dev-purge.ts +8 -2
  27. package/src/dev-render.ts +5 -2
  28. package/src/dev-roles.ts +26 -2
  29. package/src/dev-runtime.ts +11 -3
  30. package/src/dev-storage.ts +8 -2
  31. package/src/dev-sync.ts +37 -2
  32. package/src/dispatch.ts +8 -0
  33. package/src/document-styles.ts +2 -1
  34. package/src/drift.ts +3 -2
  35. package/src/error-catalog.ts +11 -1
  36. package/src/error-codes.ts +19 -2
  37. package/src/error-contract.ts +30 -7
  38. package/src/error-pages.ts +79 -0
  39. package/src/errors.ts +26 -29
  40. package/src/favicon.ts +113 -0
  41. package/src/fix-path.ts +104 -0
  42. package/src/flag-reads.ts +2 -2
  43. package/src/generate-write.ts +3 -2
  44. package/src/guards.ts +4 -4
  45. package/src/hold.ts +73 -7
  46. package/src/index.ts +16 -6
  47. package/src/island-bundle.ts +32 -4
  48. package/src/island-routes.ts +7 -1
  49. package/src/live-routes.ts +181 -0
  50. package/src/mcp-errors.ts +7 -2
  51. package/src/messages.ts +6 -4
  52. package/src/metrics-endpoint.ts +0 -2
  53. package/src/output.ts +2 -2
  54. package/src/prerender.ts +64 -22
  55. package/src/script-csp.ts +17 -0
  56. package/src/serve.ts +12 -1
  57. package/src/static-report.ts +41 -3
  58. package/src/templates/admin-page.ts +11 -7
  59. package/src/templates/imports.ts +26 -0
  60. package/src/templates/route.ts +2 -2
  61. package/src/templates/scaffold-app.ts +18 -6
  62. package/src/templates/scaffold-auth.ts +151 -0
  63. package/src/templates/scaffold-container.ts +12 -0
  64. package/src/templates/scaffold-docs.ts +1 -1
  65. package/src/templates/scaffold-domain-package.ts +3 -1
  66. package/src/templates/scaffold-mcp-package.ts +6 -3
  67. package/src/templates/scaffold-repo.ts +17 -8
  68. package/src/templates/slice-foundation.ts +3 -5
  69. package/src/test-shards.ts +2 -2
  70. package/src/tsconfig-references.ts +2 -2
  71. package/src/verify-checks.ts +10 -3
  72. package/src/verify-floor.ts +8 -6
  73. package/src/verify-run.ts +2 -2
  74. package/src/verify-step.ts +2 -1
  75. package/src/verify-test-run.ts +2 -2
  76. package/src/workspace-checks.ts +10 -12
  77. package/src/workspace-graph.ts +3 -2
  78. package/src/write-line.ts +7 -1
package/src/cmd-new.ts CHANGED
@@ -5,10 +5,11 @@
5
5
  import { existsSync } from 'node:fs';
6
6
  import { chmod } from 'node:fs/promises';
7
7
  import { isAbsolute, join, resolve } from 'node:path';
8
- import { renderThrowable } from '@ultimat3/core';
8
+ import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
9
9
  import { dedupe } from './cmd-generate';
10
10
  import type { CliCommand, CommandContext } from './command';
11
- import { MissingPositionalError } from './errors';
11
+ import { invocationOf } from './command';
12
+ import { AppNameIsPathError, MissingPositionalError } from './errors';
12
13
  import type { Runner } from './exec';
13
14
  import { msg } from './messages';
14
15
  import type { CommandResult } from './output';
@@ -116,7 +117,8 @@ export interface WrittenApp {
116
117
  * a migration whose snapshot never existed is what made the app's first two database commands
117
118
  * refuse each other — `x db migrate` naming `x db gen`, and `x db gen` refusing a sidecar version
118
119
  * control never had. The consequence is deliberate: `x verify`'s `drift` step is red on a pristine
119
- * scaffold until `x db gen "initial"` runs, which is what `cli.new.done` tells the author to do.
120
+ * scaffold until `x db gen "initial"` runs — which `bin/setup` does, and which is what
121
+ * `cli.new.done` tells the author to run.
120
122
  */
121
123
  export async function writeNewApp(target: string, options: NewAppOptions): Promise<WrittenApp> {
122
124
  const files = planNewApp(options);
@@ -130,6 +132,24 @@ function parentDir(cwd: string, dirFlag: string | undefined): string {
130
132
  return isAbsolute(dirFlag) ? dirFlag : join(cwd, dirFlag);
131
133
  }
132
134
 
135
+ /**
136
+ * The `--dir`/name split of a positional that is really a path, or `undefined` when it is a name.
137
+ *
138
+ * Separator-agnostic: a Windows path pasted into a shell here is the same mistake, and `\` is not
139
+ * a character any app name may hold either.
140
+ */
141
+ export function appNamePath(raw: string): { parent: string; base: string } | undefined {
142
+ if (!/[\\/]/.test(raw)) return undefined;
143
+ const trimmed = raw.replace(/[\\/]+$/, '');
144
+ const segments = trimmed.split(/[\\/]+/).filter((segment) => segment.length > 0);
145
+ const base = segments.at(-1) ?? 'myapp';
146
+ const parent = trimmed.slice(0, trimmed.lastIndexOf(base)).replace(/[\\/]+$/, '');
147
+ // `x new ./shop` and `x new shop/` both name the directory the caller is already in; `/shop`
148
+ // names the root, which is a directory and not "here".
149
+ if (parent !== '' && parent !== '.') return { parent, base };
150
+ return { parent: /^[\\/]/.test(trimmed) ? '/' : '.', base };
151
+ }
152
+
133
153
  export const newCommand: CliCommand = {
134
154
  spec: {
135
155
  name: 'new',
@@ -143,7 +163,7 @@ export const newCommand: CliCommand = {
143
163
  {
144
164
  // The summary carries the default and the negation because the page has to answer "which
145
165
  // one do I get if I type neither": the usage line offered `--no-example`, this table said
146
- // `--example`, and `default: true` is a field only `--json` renders. 134 files against 107.
166
+ // `--example`, and `default: true` is a field only `--json` renders. 136 files against 109.
147
167
  name: 'example',
148
168
  type: 'boolean',
149
169
  summary: 'include the example feature slice (default: on; --no-example for an empty app/)',
@@ -168,9 +188,19 @@ export const newCommand: CliCommand = {
168
188
  throw new MissingPositionalError({
169
189
  command: 'new',
170
190
  positional: 'name',
171
- example: 'x new myapp',
191
+ // Never the literal `x new`: this command is also the whole of `bunx create-ultimate`,
192
+ // which runs BEFORE `x` exists — so the one instruction the reader was given named a
193
+ // binary they had not installed yet.
194
+ example: `${invocationOf(ctx, 'new')} myapp`,
172
195
  });
173
196
  }
197
+ // Before `names()`, which is what makes this reachable at all: it slugifies a path into one
198
+ // kebab-case directory name, so `/srv/apps/shop` becomes `srv-apps-shop` inside the cwd and
199
+ // the scaffold lands somewhere nobody asked for. `--dir` is the flag that takes a path.
200
+ const path = appNamePath(raw);
201
+ if (path !== undefined) {
202
+ throw new AppNameIsPathError({ name: raw, invocation: invocationOf(ctx, 'new'), ...path });
203
+ }
174
204
  const app = names(raw);
175
205
  const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
176
206
  const options: NewAppOptions = { name: raw, example: ctx.args.flags.get('example') !== false };
@@ -195,7 +225,7 @@ export const newCommand: CliCommand = {
195
225
  code: 'X_GENERATE_CONFLICT',
196
226
  cause: `${target} already exists`,
197
227
  fix: `x new ${app.kebab} --force, or choose another name`,
198
- docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
228
+ docs: ERROR_DOCS_URL,
199
229
  at: target,
200
230
  },
201
231
  ],
package/src/cmd-shot.ts CHANGED
@@ -164,6 +164,17 @@ const intFlag = (
164
164
  fallback,
165
165
  );
166
166
 
167
+ /**
168
+ * How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
169
+ * call, for the reason every `Runner` in this package is one: the failure path below — a boot that
170
+ * throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
171
+ */
172
+ export type BootDevServer = (input: {
173
+ readonly root: string;
174
+ readonly port: number;
175
+ readonly env: Readonly<Record<string, string | undefined>>;
176
+ }) => Promise<{ readonly url: string; stop(): Promise<void> }>;
177
+
167
178
  export interface ShotServer {
168
179
  readonly url: string;
169
180
  /** Which server the picture is of. Reported, because the two have different failure modes. */
@@ -272,6 +283,7 @@ export async function devServerFor(
272
283
  root: string,
273
284
  env: Readonly<Record<string, string | undefined>>,
274
285
  port: number,
286
+ boot: BootDevServer = (input) => startDev(input),
275
287
  ): Promise<ShotServer> {
276
288
  const services = resolveServices(root, env);
277
289
  const file = Bun.file(lockPath(services.stateDir));
@@ -281,13 +293,21 @@ export async function devServerFor(
281
293
  return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
282
294
  }
283
295
  }
284
- await preflight({
296
+ const { release } = await preflight({
285
297
  stateDir: services.stateDir,
286
298
  port,
287
299
  hostname: DEV_BINDING.hostname,
288
300
  embeddedDb: services.db.mode === 'embedded',
289
301
  });
290
- const dev = await startDev({ root, port, env });
302
+ // The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
303
+ // looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
304
+ // same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
305
+ // checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
306
+ // teardown must never replace the failure it is cleaning up after.
307
+ const dev = await boot({ root, port, env }).catch((error: unknown) => {
308
+ release();
309
+ throw error;
310
+ });
291
311
  await writeLock(services.stateDir, {
292
312
  pid: process.pid,
293
313
  port,
package/src/command.ts CHANGED
@@ -13,8 +13,20 @@ export interface CommandContext {
13
13
  readonly runner: Runner;
14
14
  readonly env: Readonly<Record<string, string | undefined>>;
15
15
  readonly bunVersion: string;
16
+ /**
17
+ * How this process was invoked, up to and including the subcommand — `x new`, or
18
+ * `bunx create-ultimate` when `create-ultimate` is the entry point. Read by a `fix:` line, which
19
+ * is a command the reader is meant to RUN: `create-ultimate`'s whole reason to exist is running
20
+ * before `x` is installed, so `x new myapp` was an instruction nobody in that process could
21
+ * follow. Absent means the default below — a caller that builds a context by hand owes nothing.
22
+ */
23
+ readonly invocation?: string;
16
24
  }
17
25
 
26
+ /** What `ctx.invocation` means when nobody said: the binary, then the subcommand they typed. */
27
+ export const invocationOf = (ctx: CommandContext, command: string): string =>
28
+ ctx.invocation ?? `x ${command}`;
29
+
18
30
  export interface CliCommand {
19
31
  readonly spec: CommandSpec;
20
32
  run(ctx: CommandContext): Promise<CommandResult>;
package/src/db-finding.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // step that failed. Its own module because `cmd-db.ts` and `cmd-db-branch.ts` both need it and
4
4
  // neither may import the other.
5
5
 
6
- import { renderThrowable } from '@ultimat3/core';
6
+ import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
7
7
  import type { Finding } from './output';
8
8
  import { findingFrom, isUltimateErrorShape } from './output';
9
9
 
@@ -24,5 +24,5 @@ export const stepFinding = (error: unknown, code: string): Finding =>
24
24
  // a TypeError raised while reporting it.
25
25
  cause: renderThrowable(error),
26
26
  fix: 'x doctor --json',
27
- docs: `https://ultimate.dev/errors/${code}`,
27
+ docs: ERROR_DOCS_URL,
28
28
  };
package/src/db-seed.ts CHANGED
@@ -12,7 +12,6 @@ import type { Environment } from '@ultimat3/core';
12
12
  import { UltimateError } from '@ultimat3/core';
13
13
  import type { Driver, Seed, SeedTier } from '@ultimat3/entity';
14
14
  import { isSeed, SEED_TIERS, seedTiersFor } from '@ultimat3/entity';
15
- import { docsFor } from './error-codes';
16
15
  import { BadFlagError } from './errors';
17
16
  import type { Finding, JsonValue } from './output';
18
17
  import { findingFrom } from './output';
@@ -49,7 +48,6 @@ export class SeedUnknownError extends UltimateError {
49
48
  // A dry run, never a bare `x db seed`: the command that answers "which seeds are there" must
50
49
  // not be the command that writes them.
51
50
  fix: 'x db seed --dry-run --json',
52
- docs: docsFor('X_DECLARATION_UNKNOWN'),
53
51
  });
54
52
  }
55
53
  }
@@ -75,7 +73,6 @@ export class SeedEnvironmentError extends UltimateError {
75
73
  code: 'X_SEED_ENVIRONMENT',
76
74
  cause: `seed "${input.seed}" is tier ${input.tier} and ULTIMATE_ENV resolved ${input.environment}, where x db seed runs ${input.tiers.join(', ')} — ULTIMATE_SEED_TIER=${input.tier} says this deploy takes it anyway`,
77
75
  fix: `x db seed ${input.seed} --tier ${input.tier} --json`,
78
- docs: docsFor('X_SEED_ENVIRONMENT'),
79
76
  });
80
77
  }
81
78
  }
package/src/dev-assets.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  authorizeStorageRead,
24
24
  STORAGE_READ_PERMISSION,
25
25
  } from './dev-storage';
26
+ import { faviconRoute } from './favicon';
26
27
 
27
28
  /**
28
29
  * The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
@@ -254,6 +255,11 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
254
255
  handler: async (request: UltimateRequest, ctx: RequestContext): Promise<Response> =>
255
256
  mediaResponse(request, ctx, options.storage, options.images),
256
257
  });
258
+ // Mounted here rather than in `serve.ts` and `cmd-dev.ts` separately: this is the one route set
259
+ // both served surfaces already compose, and a favicon that answers on a laptop and 404s in the
260
+ // container is the dev/prod difference this package's own rule forbids. `favicon.ts` owns what
261
+ // the answer IS — this file only says the app's asset surface is where it hangs.
262
+ routes.push(faviconRoute(options.root));
257
263
 
258
264
  return routes;
259
265
  }
package/src/dev-cache.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  resetTiers,
21
21
  } from '@ultimat3/cache';
22
22
  import type { CacheTierName } from '@ultimat3/core';
23
- import { CACHE_TIERS, defineConfig, logger } from '@ultimat3/core';
23
+ import { CACHE_TIERS, defineConfig, logger, renderThrowable } from '@ultimat3/core';
24
24
  import type { Transport, TransportSubscription } from '@ultimat3/realtime/server';
25
25
  import { APP_CONFIG_EXPORT } from './app-auth';
26
26
  import { APP_CONFIG_FILE } from './app-root';
@@ -187,7 +187,7 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
187
187
  void applyBroadcast(payload);
188
188
  })
189
189
  .catch((error: unknown) => {
190
- logger.warn('cache.broadcast.subscribe-failed', { error: messageOf(error) });
190
+ logger.warn('cache.broadcast.subscribe-failed', { error: broadcastErrorText(error) });
191
191
  return undefined;
192
192
  });
193
193
 
@@ -199,8 +199,15 @@ export function startCacheTiers(options: CacheTiersOptions): () => Promise<void>
199
199
  };
200
200
  }
201
201
 
202
- const messageOf = (error: unknown): string =>
203
- error instanceof Error ? error.message : 'unknown error';
202
+ /**
203
+ * `renderThrowable`, never `instanceof Error` + `.message`. Both run on a value this process did
204
+ * not build — a `Proxy` traps `getPrototypeOf` and a `message` getter can raise — and a throw here
205
+ * is inside the handler whose whole job is to keep the subscriber loop alive: losing it ends
206
+ * cross-instance cache invalidation for the process, quietly, which is the failure the loop's own
207
+ * `try` exists to prevent. The old form also answered `'unknown error'` for every non-`Error`
208
+ * throw, so a driver rejecting with a string reported nothing at all.
209
+ */
210
+ export const broadcastErrorText = (error: unknown): string => renderThrowable(error);
204
211
 
205
212
  /**
206
213
  * A peer's wire tags, applied here. Never throws: a malformed frame or an undeclared tag must not
@@ -214,6 +221,6 @@ async function applyBroadcast(payload: string): Promise<void> {
214
221
  const wire = parsed.filter((value): value is string => typeof value === 'string');
215
222
  if (wire.length > 0) await receiveInvalidationBroadcast(wire);
216
223
  } catch (error) {
217
- logger.warn('cache.broadcast.apply-failed', { error: messageOf(error) });
224
+ logger.warn('cache.broadcast.apply-failed', { error: broadcastErrorText(error) });
218
225
  }
219
226
  }
package/src/dev-hooks.ts CHANGED
@@ -41,14 +41,22 @@ export interface DevHookOptions {
41
41
  * that starts a web role — `serve.ts` included — carry a diagnostic only one of them installs.
42
42
  */
43
43
  readonly devNotices?: ServerHooks['devNotices'];
44
+ /**
45
+ * The app's own error page for a status, read off its disk. Passed for `devNotices`' reason: a
46
+ * hook that reached for the app root itself would make every host that starts a web role carry a
47
+ * path only the one that knows the root can supply.
48
+ */
49
+ readonly errorPage?: ServerHooks['errorPage'];
44
50
  }
45
51
 
46
52
  export function devHooks(options: DevHookOptions = {}): ServerHooks {
47
53
  const authenticate = configuredAuthenticator();
48
54
  const devNotices = options.devNotices;
55
+ const errorPage = options.errorPage;
49
56
  return {
50
57
  ...(authenticate === undefined ? {} : { authenticate }),
51
58
  ...(devNotices === undefined ? {} : { devNotices }),
59
+ ...(errorPage === undefined ? {} : { errorPage }),
52
60
  authorize: (route, _request, ctx): AuthzDecision => {
53
61
  // An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline
54
62
  // never asks. `invoke` is its one evaluation, and the only one holding the row a row-level
package/src/dev-lock.ts CHANGED
@@ -16,8 +16,7 @@
16
16
 
17
17
  import { closeSync, mkdirSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
- import { UltimateError } from '@ultimat3/core';
20
- import { docsFor } from './error-codes';
19
+ import { stringField, UltimateError } from '@ultimat3/core';
21
20
  import { exec, type Runner } from './exec';
22
21
  import { quoteArg } from './shell-quote';
23
22
 
@@ -68,7 +67,11 @@ export const isProcessAlive = (pid: number): boolean => {
68
67
  return true;
69
68
  } catch (error) {
70
69
  // EPERM means it exists and belongs to another user. Alive, and not ours to signal.
71
- return (error as { code?: string }).code === 'EPERM';
70
+ // `stringField`, never a cast plus a property read: the rule `metrics-endpoint.ts` states and
71
+ // `caught-value-reads.test.ts` enforces — a getter that throws would take this path down one
72
+ // line before the guard meant to make it safe, and this guard decides whether a second `x dev`
73
+ // is allowed to open a single-writer data directory.
74
+ return stringField(error, 'code') === 'EPERM';
72
75
  }
73
76
  };
74
77
 
@@ -96,7 +99,6 @@ export class DevAlreadyRunningError extends UltimateError {
96
99
  code: 'X_DEV_ALREADY_RUNNING',
97
100
  cause: `pid ${input.lock.pid} is already running x dev on ${input.lock.url} and holds ${input.stateDir}${single}`,
98
101
  fix: `use the one already running at ${input.lock.url}, or stop it: kill ${input.lock.pid}`,
99
- docs: docsFor('X_DEV_ALREADY_RUNNING'),
100
102
  meta: { pid: input.lock.pid, port: input.lock.port, stateDir: input.stateDir },
101
103
  });
102
104
  }
@@ -118,7 +120,6 @@ export class DevLockUnreadableError extends UltimateError {
118
120
  code: 'X_DEV_LOCK_UNREADABLE',
119
121
  cause: `${input.path} could not be parsed as a dev lock and could not be removed, so x dev cannot tell whether another process owns ${input.stateDir}`,
120
122
  fix: `rm ${quoteArg(input.path)} # then re-run x dev`,
121
- docs: docsFor('X_DEV_LOCK_UNREADABLE'),
122
123
  meta: { path: input.path, stateDir: input.stateDir },
123
124
  });
124
125
  }
@@ -198,7 +199,6 @@ export class DevPortInUseError extends UltimateError {
198
199
  holder.pid === undefined
199
200
  ? `x dev --port ${input.suggestion}`
200
201
  : `x dev --port ${input.suggestion} # or free it, if that pid is yours: kill ${holder.pid}`,
201
- docs: docsFor('X_PORT_IN_USE'),
202
202
  meta: { port: input.port, ...holder },
203
203
  });
204
204
  }
@@ -247,7 +247,8 @@ function claimExclusive(path: string, lock: DevLock): boolean {
247
247
  try {
248
248
  fd = openSync(path, 'wx');
249
249
  } catch (error) {
250
- if ((error as { code?: string }).code === 'EEXIST') return false;
250
+ // Same rule as `isProcessAlive` above: read the field, never cast and dereference.
251
+ if (stringField(error, 'code') === 'EEXIST') return false;
251
252
  throw error;
252
253
  }
253
254
  try {
package/src/dev-purge.ts CHANGED
@@ -112,9 +112,15 @@ function declareSweep(): void {
112
112
  * background work at all, and this is one more thing it does not do.
113
113
  */
114
114
  export function installRetentionSweep(stores: RetentionStores): () => void {
115
- installed = retentionTargets(stores);
115
+ const mine = retentionTargets(stores);
116
+ installed = mine;
116
117
  declareSweep();
117
118
  return () => {
118
- installed = [];
119
+ // Only if the slot is still OURS. Two runtimes can share one process — a test harness, or
120
+ // `x shot`'s scratch server beside the one it photographs — and a boot that installed after
121
+ // this one owns the slot now: emptying it there would leave the LIVE boot's hourly sweep
122
+ // deleting nothing, forever, with no error anywhere. Same rule, and the same comment, as
123
+ // `installedRevalidator` in `@ultimat3/render`'s ISR controller.
124
+ if (installed === mine) installed = [];
119
125
  };
120
126
  }
package/src/dev-render.ts CHANGED
@@ -183,11 +183,14 @@ async function resultFor(
183
183
  return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
184
184
  }
185
185
  case 'isr': {
186
- // `isrKey(url)`, never `url.pathname`: the query is part of what was rendered — this
186
+ // `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — this
187
187
  // route's own `meta` reads `data.url` — so two URLs differing only in their query are two
188
188
  // documents. Keyed on the pathname alone, the first render answered every later query
189
189
  // string (#171). Render owns the derivation so no second caller can invent another.
190
- const served = await isr.serve(isrKey(url), () =>
190
+ // The locale is the second dimension and it is `ctx.locale`, the answer the `locale` stage
191
+ // already negotiated for THIS request — never `currentLocale()`, which would read the same
192
+ // value through an ambient store the key does not need.
193
+ const served = await isr.serve(isrKey(url, ctx.locale), () =>
191
194
  documentFrom(entry, request, data, options),
192
195
  );
193
196
  return served.result;
package/src/dev-roles.ts CHANGED
@@ -26,9 +26,11 @@ import { startReplicator } from './dev-replicator';
26
26
  import type { RunningServices } from './dev-runtime';
27
27
  import type { Env } from './dev-services';
28
28
  import { startSync } from './dev-sync';
29
+ import { errorPageHook } from './error-pages';
29
30
  import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
30
31
  import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
31
32
  import type { RuntimeOverrides } from './runtime-overrides';
33
+ import { inlineScriptSources } from './script-csp';
32
34
  import { inlineStyleSources } from './style-csp';
33
35
 
34
36
  /** The roles `x dev` starts when `--role` names none, in boot order. */
@@ -63,6 +65,16 @@ export interface StartRolesOptions {
63
65
  * `startRoles` takes plain values — a test starts a web role with no `app.config.ts` at all.
64
66
  */
65
67
  readonly signInPath?: string | null;
68
+ /**
69
+ * The app root, for the one seam that is a FILE and not a value: `apps/web/site/errors/404.html`
70
+ * and its siblings. Bound HERE rather than passed by each caller, because `x dev` and `serve.ts`
71
+ * both boot through this function and an override wired at one of them alone is a page that
72
+ * appears in dev and not in production — `/favicon.ico`'s rule, one seam over.
73
+ *
74
+ * Optional for the reason `signInPath` is: `startRoles` takes plain values, and a test starts a
75
+ * web role with no app on disk at all. Absent, every error page is the framework's.
76
+ */
77
+ readonly root?: string;
66
78
  /**
67
79
  * Inline `<style>` bodies this process serves that the app's own surfaces do not account for —
68
80
  * `/_x`'s shell. The surfaces themselves are read from the stylesheet registry here rather than
@@ -235,7 +247,10 @@ function startWeb(options: StartRolesOptions): ServerHandle {
235
247
  return createServer({
236
248
  routes: options.routes,
237
249
  role: 'web',
238
- hooks: devHooks(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
250
+ hooks: devHooks({
251
+ ...(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
252
+ ...(options.root === undefined ? {} : { errorPage: errorPageHook(options.root) }),
253
+ }),
239
254
  // Both seams `createServer` already had and `startRoles` passed neither of, so an app's own
240
255
  // middleware could not reach the pipeline any process the framework boots actually runs.
241
256
  ...(options.overrides?.middleware === undefined
@@ -260,8 +275,17 @@ function startWeb(options: StartRolesOptions): ServerHandle {
260
275
  // Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
261
276
  // nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
262
277
  // function of that body. Read after `loadApp` — importing the app IS what registered them.
278
+ // BOTH directives, and the script half is the one that was missing: the hydration runtime
279
+ // is emitted inline in every document that carries an island, so `script-src 'self'` meant
280
+ // no island booted anywhere the policy is enforced — which is every container, and never
281
+ // `x dev`, where it is report-only.
263
282
  security: {
264
- csp: { extend: { 'style-src': inlineStyleSources(options.inlineStyles ?? []) } },
283
+ csp: {
284
+ extend: {
285
+ 'style-src': inlineStyleSources(options.inlineStyles ?? []),
286
+ 'script-src': inlineScriptSources(),
287
+ },
288
+ },
265
289
  },
266
290
  }),
267
291
  }).start();
@@ -186,8 +186,14 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
186
186
  // missing" from a helper the operator never configured. Not an outright ban on the local disk in
187
187
  // production: a single-node Compose deploy on a mounted volume WITH a real secret is a rung on
188
188
  // the scale ladder, and refusing it would be a deploy-shape decision, not a security fix.
189
- if (!isLocal() && usesDevStorageSecret()) {
190
- throw new LocalDiskUnsafeError({ environment: resolveEnvironment(), root });
189
+ // `{ env }` on ALL THREE, never the ambient `process.env`: this function is HANDED the boot's
190
+ // environment and reads `S3_BUCKET` off it one branch above, so a guard asking a second source
191
+ // could answer `development` for a process booting as `production` — or, with
192
+ // `usesDevStorageSecret({ env })` left bare, refuse a boot whose own env carries a real
193
+ // `STORAGE_SIGNING_SECRET` because the PROCESS does not. Which environment, whether a secret
194
+ // exists, and the name the message prints are one question about one table.
195
+ if (!isLocal({ env }) && usesDevStorageSecret({ env })) {
196
+ throw new LocalDiskUnsafeError({ environment: resolveEnvironment({ env }), root });
191
197
  }
192
198
  try {
193
199
  mkdirSync(root, { recursive: true });
@@ -198,7 +204,9 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
198
204
  `mount a writable volume at ${root}, or set S3_ENDPOINT and S3_BUCKET to use object storage instead`,
199
205
  );
200
206
  }
201
- return defineStorage({ disks: { local: localDriver({ root }) }, default: 'local' });
207
+ // The guard three lines up reads `env`; so must the disk it guards. Otherwise the boot's
208
+ // environment decides whether signing is allowed and the process's decides what key is used.
209
+ return defineStorage({ disks: { local: localDriver({ root, env }) }, default: 'local' });
202
210
  }
203
211
 
204
212
  /**
@@ -151,8 +151,14 @@ export function parseByteRange(
151
151
  if (from === '' && to === '') return undefined;
152
152
  if (from === '') {
153
153
  const wanted = Number(to);
154
- // A suffix longer than the object is the whole object, not a refusal.
155
- return wanted === 0 ? UNSATISFIABLE : { start: Math.max(size - wanted, 0), end: size - 1 };
154
+ // A suffix longer than the object is the whole object, not a refusal — but there is no whole
155
+ // object to fall back to at `size === 0`, and the arithmetic below answers `{ start: 0, end:
156
+ // -1 }`, which the route renders as `content-range: bytes 0--1/0` with status 206. RFC 9110
157
+ // requires 416. The non-suffix branch already gets this right through `start >= size`; this
158
+ // one had no equivalent test.
159
+ return size === 0 || wanted === 0
160
+ ? UNSATISFIABLE
161
+ : { start: Math.max(size - wanted, 0), end: size - 1 };
156
162
  }
157
163
  const start = Number(from);
158
164
  const end = to === '' ? size - 1 : Math.min(Number(to), size - 1);
package/src/dev-sync.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Split from `dev-roles.ts` because it is the one role with an authenticator, a presence registry
3
3
  // and a listener of its own — and because that file is the boot's index, not its detail.
4
4
 
5
- import { createContext, logger } from '@ultimat3/core';
5
+ import { createContext, logger, UltimateError } from '@ultimat3/core';
6
6
  import { listQueries } from '@ultimat3/query';
7
7
  import {
8
8
  ChannelHub,
@@ -15,8 +15,43 @@ 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
19
  import { syncAuthenticator } from './sync-authenticator';
19
20
 
21
+ /**
22
+ * Beside its one thrower rather than in `errors.ts`, which is at 461 of the 500-line ceiling —
23
+ * the arrangement `db-seed.ts` and `metrics-endpoint.ts` already take. The code is
24
+ * `X_PORT_INVALID`, this package's own: "the port asked for is not one" is what it already means,
25
+ * and a second code for the same fact is the synonym the registry exists to prevent.
26
+ */
27
+ class SyncPortUnavailableError extends UltimateError {
28
+ constructor(input: { port: number }) {
29
+ super({
30
+ code: 'X_PORT_INVALID',
31
+ cause: `the sync role binds PORT + 1, and PORT=${input.port} is the top of the range — it would ask for ${input.port + 1}, which is not a TCP port`,
32
+ fix: `x dev --port ${neighbouringPort(input.port)} # leaves ${PORT_RANGE.max} free for the sync node`,
33
+ meta: { port: input.port },
34
+ });
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The port the sync node listens on. `PORT + 1`, and `0` stays `0` — the kernel picks, and adding
40
+ * one to it would pick a specific port instead.
41
+ *
42
+ * REFUSED at the top of the range, never clamped. `PORT_RANGE.max` is 65535 and `portValue`
43
+ * accepts it, so `x dev --port 65535` handed `Bun.serve` 65536 and the bare `RangeError` reached
44
+ * the terminal as `X_CLI_UNEXPECTED` with `fix: x doctor --json`. Clamping to 65534 would be worse
45
+ * than refusing: `PORT + 1` is the rule `docker/docker-compose.prod.yml` publishes `3001:3001`
46
+ * from and `docker/helm` derives `PORT = .port - 1` from, so a node quietly on `PORT - 1` is a
47
+ * socket nothing else in the deployment computes.
48
+ */
49
+ export function syncPortFor(port: number): number {
50
+ if (port === 0) return 0;
51
+ if (port >= PORT_RANGE.max) throw new SyncPortUnavailableError({ port });
52
+ return port + 1;
53
+ }
54
+
20
55
  /** What `startRoles` holds on to: where the node listens, and how to take it down. */
21
56
  export interface RunningSync {
22
57
  readonly url: string;
@@ -97,7 +132,7 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
97
132
  });
98
133
  await node.start();
99
134
  try {
100
- const listener = listenSyncNode(node, { port: options.port === 0 ? 0 : options.port + 1 });
135
+ const listener = listenSyncNode(node, { port: syncPortFor(options.port) });
101
136
  return {
102
137
  url: listener.url,
103
138
  stop: async () => {
package/src/dispatch.ts CHANGED
@@ -31,6 +31,13 @@ export interface DispatchOptions {
31
31
  * it cannot produce. `bin.ts` passes the real fd 2.
32
32
  */
33
33
  readonly writeError?: (line: string) => void;
34
+ /**
35
+ * How the caller invoked this process, up to and including the subcommand — passed straight to
36
+ * `CommandContext.invocation` and read by the `fix:` lines that quote a whole command back.
37
+ * `create-ultimate` is the one caller that supplies it, because it is the one entry point whose
38
+ * name is not `x`.
39
+ */
40
+ readonly invocation?: string;
34
41
  }
35
42
 
36
43
  /**
@@ -122,6 +129,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
122
129
  runner: options.runner ?? exec,
123
130
  env: options.env,
124
131
  bunVersion: options.bunVersion,
132
+ ...(options.invocation === undefined ? {} : { invocation: options.invocation }),
125
133
  };
126
134
 
127
135
  try {
@@ -4,6 +4,7 @@
4
4
  // browser drops every one of those declarations from, byte-for-byte identical to a working page
5
5
  // apart from the styling nobody sees missing. A silent failure is exactly what axiom 3 exists for.
6
6
 
7
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
7
8
  import type { Surface } from '@ultimat3/render';
8
9
  import { routeEntries } from '@ultimat3/render';
9
10
  import { stylesFor } from '@ultimat3/render/server';
@@ -49,7 +50,7 @@ export function checkDocumentStyles(documents: readonly SurfaceDocument[]): read
49
50
  code: 'X_STYLES_GLOBAL_MISSING',
50
51
  cause: `a ${document.surface}/ document carries ${document.css.length} characters of CSS and defines no :root custom properties, so every var(--color-*) and var(--space-*) in it resolves to nothing`,
51
52
  fix: `add apps/web/${APP_GLOBAL_STYLESHEET} containing \`@use '@ultimat3/ui/global.scss';\` and apps/web/${APP_GLOBAL_MODULE} containing \`import './global.scss';\``,
52
- docs: 'https://ultimate.dev/errors/X_STYLES_GLOBAL_MISSING',
53
+ docs: ERROR_DOCS_URL,
53
54
  at: `apps/web/${APP_GLOBAL_STYLESHEET}`,
54
55
  }));
55
56
  }
package/src/drift.ts CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
14
15
  import { describeEntities } from '@ultimat3/entity';
15
16
  import { countDeclaredEntities } from './app-entities';
16
17
  import { loadApp } from './app-load';
@@ -181,7 +182,7 @@ export async function checkSourceDrift(
181
182
  code: 'X_DB_DRIFT',
182
183
  cause: 'packages/db has a schema but no migration recorded it',
183
184
  fix: 'x db gen "initial"',
184
- docs: 'https://ultimate.dev/errors/X_DB_DRIFT',
185
+ docs: ERROR_DOCS_URL,
185
186
  at: MIGRATIONS_DIR,
186
187
  },
187
188
  ];
@@ -192,7 +193,7 @@ export async function checkSourceDrift(
192
193
  code: 'X_DB_DRIFT',
193
194
  cause: `schema hashes to ${current}, newest migration ${latest.file} recorded ${latest.hash}`,
194
195
  fix: 'x db gen "describe the change"',
195
- docs: 'https://ultimate.dev/errors/X_DB_DRIFT',
196
+ docs: ERROR_DOCS_URL,
196
197
  at: `${DB_PACKAGE}/src`,
197
198
  },
198
199
  ];
@@ -44,12 +44,22 @@ export const CATALOG_PACKAGES = [
44
44
  '@ultimat3/ui',
45
45
  ] as const;
46
46
 
47
+ /**
48
+ * The two packages the catalog may import WITHOUT `@ultimat3/cli` declaring them: they reach for a
49
+ * JSX runtime an app has and a bare CLI process does not, so a hard dependency would make the CLI
50
+ * uninstallable where the codes are merely absent today. Every other entry above is a real runtime
51
+ * import and must be a declared dependency — `error-catalog.test.ts` holds the list to exactly that,
52
+ * because an undeclared one resolves through workspace symlinks here and through nothing in an
53
+ * installed app, where `x errors explain X_FLAG_EXPIRED` then refuses a code the wiki promises.
54
+ */
55
+ export const CATALOG_OPTIONAL_HOSTS: readonly string[] = ['@ultimat3/admin', '@ultimat3/ui'];
56
+
47
57
  export interface ErrorCatalog {
48
58
  /** Packages whose codes are now registered. */
49
59
  readonly loaded: readonly string[];
50
60
  /**
51
61
  * Packages this process could not *resolve*, so their codes are absent from the answer. The one
52
- * tolerated case is the optional host: `@ultimat3/ui` and `@ultimat3/admin` reach for a JSX
62
+ * tolerated case is the optional host: `CATALOG_OPTIONAL_HOSTS` reach for a JSX
53
63
  * runtime an app has and a bare CLI process does not, and a list silently missing their codes is
54
64
  * worse than one that says which packages are missing. A package that resolved and then threw is
55
65
  * a defect, not a host gap, and goes to `failed`.