@ultimat3/cli 23.0.0 → 24.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 (149) hide show
  1. package/CLAUDE.md +15 -7
  2. package/README.md +3 -3
  3. package/package.json +28 -28
  4. package/src/affected.ts +27 -35
  5. package/src/api-registration.ts +89 -0
  6. package/src/app-artifacts.ts +1 -1
  7. package/src/app-auth.ts +4 -27
  8. package/src/app-config-load.ts +51 -0
  9. package/src/app-env.ts +5 -11
  10. package/src/app-manifest.ts +2 -8
  11. package/src/app-mcp.ts +10 -23
  12. package/src/app-permissions-borrowed.ts +129 -0
  13. package/src/app-permissions-site.ts +24 -0
  14. package/src/app-permissions.ts +7 -17
  15. package/src/budgets.ts +4 -35
  16. package/src/cdp-shot-driver.ts +3 -1
  17. package/src/cmd-auth-spec.ts +16 -0
  18. package/src/cmd-auth.ts +65 -0
  19. package/src/cmd-build.ts +64 -15
  20. package/src/cmd-db-backfill.ts +25 -12
  21. package/src/cmd-db.ts +6 -5
  22. package/src/cmd-deploy-helm.ts +7 -6
  23. package/src/cmd-deploy.ts +4 -4
  24. package/src/cmd-dev.ts +9 -8
  25. package/src/cmd-doctor-spec.ts +2 -9
  26. package/src/cmd-doctor.ts +41 -14
  27. package/src/cmd-env.ts +26 -2
  28. package/src/cmd-generate.ts +79 -32
  29. package/src/cmd-help.ts +19 -6
  30. package/src/cmd-i18n.ts +10 -9
  31. package/src/cmd-jobs.ts +18 -6
  32. package/src/cmd-manifest-spec.ts +1 -1
  33. package/src/cmd-manifest.ts +6 -25
  34. package/src/cmd-mcp.ts +46 -15
  35. package/src/cmd-planned.ts +15 -8
  36. package/src/cmd-routes.ts +12 -2
  37. package/src/cmd-secrets.ts +47 -6
  38. package/src/cmd-shot-matrix.ts +1 -3
  39. package/src/cmd-shot.ts +12 -3
  40. package/src/cmd-tasks.ts +2 -2
  41. package/src/cmd-test-spec.ts +1 -7
  42. package/src/cmd-test.ts +13 -29
  43. package/src/cmd-verify.ts +4 -4
  44. package/src/db-subscribes.ts +12 -4
  45. package/src/dev-dashboard-guard.ts +76 -0
  46. package/src/dev-dashboard.ts +51 -29
  47. package/src/dev-lock.ts +4 -7
  48. package/src/dispatch.ts +3 -6
  49. package/src/doctor-app-url.ts +39 -0
  50. package/src/doctor-auth.ts +95 -0
  51. package/src/drift-replica-identity.ts +42 -0
  52. package/src/error-catalog.ts +6 -9
  53. package/src/error-codes.ts +23 -3
  54. package/src/error-unthrown.ts +29 -11
  55. package/src/errors.ts +54 -83
  56. package/src/framework-schema-apply.ts +78 -0
  57. package/src/framework-schema-stamp.ts +96 -0
  58. package/src/framework-schema.ts +13 -2
  59. package/src/generate-files.ts +4 -3
  60. package/src/generate-kinds.ts +33 -1
  61. package/src/generate-shadow.ts +46 -0
  62. package/src/generate-write.ts +47 -13
  63. package/src/gh-target.ts +12 -7
  64. package/src/i18n-audit.ts +4 -1
  65. package/src/index.ts +1 -3
  66. package/src/island-bundle.ts +3 -15
  67. package/src/jobs-drain.ts +82 -15
  68. package/src/jobs-report.ts +4 -4
  69. package/src/loopback-host.ts +13 -0
  70. package/src/manifest-staleness.ts +62 -0
  71. package/src/mcp-errors.ts +12 -3
  72. package/src/mcp-host.ts +16 -9
  73. package/src/mcp-test-run.ts +38 -0
  74. package/src/messages.ts +2 -0
  75. package/src/metrics-endpoint.ts +25 -1
  76. package/src/output.ts +6 -0
  77. package/src/page-navigation.ts +7 -23
  78. package/src/page-speculation.ts +5 -20
  79. package/src/prerender-out.ts +34 -7
  80. package/src/prerender.ts +8 -5
  81. package/src/pwa-artifacts.ts +26 -94
  82. package/src/registry.ts +2 -0
  83. package/src/revalidated-response.ts +4 -3
  84. package/src/role-replicator.ts +16 -0
  85. package/src/role-start.ts +9 -4
  86. package/src/role-sync.ts +55 -20
  87. package/src/runtime-assets.ts +11 -7
  88. package/src/runtime-bindings.ts +4 -4
  89. package/src/runtime-cache.ts +38 -47
  90. package/src/runtime-idempotency-scope.ts +23 -0
  91. package/src/runtime-isr.ts +36 -0
  92. package/src/runtime-jobs.ts +8 -33
  93. package/src/runtime-mfa-warning.ts +29 -0
  94. package/src/runtime-notify-retention.ts +10 -46
  95. package/src/runtime-overrides.ts +1 -1
  96. package/src/runtime-purge.ts +38 -16
  97. package/src/runtime-queue.ts +31 -10
  98. package/src/runtime-realtime.ts +9 -45
  99. package/src/runtime-render.ts +62 -35
  100. package/src/runtime-services.ts +20 -17
  101. package/src/runtime-storage.ts +10 -5
  102. package/src/schema-diff.ts +16 -0
  103. package/src/schema-drift.ts +45 -15
  104. package/src/seo-routes.ts +80 -9
  105. package/src/serve-boot.ts +34 -6
  106. package/src/serve-drain.ts +9 -33
  107. package/src/serve-env.ts +9 -6
  108. package/src/serve-web.ts +12 -7
  109. package/src/shot-locale.ts +5 -23
  110. package/src/site-assets.ts +3 -5
  111. package/src/site-config.ts +9 -48
  112. package/src/site-seo.ts +1 -1
  113. package/src/sitemap-lastmod.ts +5 -0
  114. package/src/solid-loader.ts +12 -3
  115. package/src/source-files.ts +6 -0
  116. package/src/stored-object-headers.ts +43 -0
  117. package/src/sync-url.ts +2 -2
  118. package/src/tasks-facts.ts +3 -29
  119. package/src/templates/action.ts +3 -0
  120. package/src/templates/naming.ts +28 -0
  121. package/src/templates/route.ts +3 -6
  122. package/src/templates/scaffold-app.ts +6 -2
  123. package/src/templates/scaffold-container-compose.ts +10 -1
  124. package/src/templates/scaffold-container.ts +4 -2
  125. package/src/templates/scaffold-db-client.ts +4 -2
  126. package/src/templates/scaffold-helm-fences.ts +76 -0
  127. package/src/templates/scaffold-helm-templates.ts +32 -7
  128. package/src/templates/scaffold-helm.ts +31 -1
  129. package/src/templates/scaffold-repo.ts +9 -1
  130. package/src/templates/scaffold-shell.ts +39 -36
  131. package/src/test-counts.ts +2 -6
  132. package/src/test-ignores.ts +25 -0
  133. package/src/test-passes.ts +0 -15
  134. package/src/test-select.ts +5 -14
  135. package/src/test-shards.ts +41 -93
  136. package/src/test-workers.ts +26 -4
  137. package/src/theme-boot.ts +6 -31
  138. package/src/trusted-hops-error.ts +17 -0
  139. package/src/unthrown-codes.ts +26 -0
  140. package/src/verify-checks.ts +17 -46
  141. package/src/verify-deadline.ts +130 -33
  142. package/src/verify-merge.ts +60 -27
  143. package/src/verify-part-step.ts +90 -0
  144. package/src/verify-run.ts +29 -8
  145. package/src/verify-shard.ts +13 -2
  146. package/src/verify-stalled.ts +152 -0
  147. package/src/verify-tests.ts +5 -2
  148. package/src/build-errors.ts +0 -54
  149. package/src/verify-errors.ts +0 -58
package/src/cmd-mcp.ts CHANGED
@@ -3,17 +3,24 @@
3
3
  // supplies only the app, the caller and the socket. A tool answered here would be a second answer
4
4
  // to a question the framework already answers.
5
5
 
6
- import { markListening, nanoid, timingSafeEqual } from '@ultimat3/core';
6
+ import {
7
+ markListening,
8
+ nanoid,
9
+ renderFixShellArg,
10
+ timingSafeEqual,
11
+ UltimateError,
12
+ } from '@ultimat3/core';
7
13
  import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
8
14
  import { requireAppRoot } from './app-root';
9
15
  import { DEFAULT_PORT, mcpSpec } from './cmd-mcp-spec';
10
16
  import type { CliCommand, CommandContext } from './command';
11
17
  import { BadFlagError } from './errors';
12
- import { intFlagOr, PORT_RANGE } from './flag-number';
18
+ import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
13
19
  import { holdUntilShutdown } from './hold';
14
20
  import type { CliMcpServer } from './mcp-host';
15
21
  import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
16
22
  import { msg } from './messages';
23
+ import { isAddressInUse } from './metrics-endpoint';
17
24
  import type { CommandResult } from './output';
18
25
  import { flagString } from './parse';
19
26
 
@@ -56,6 +63,21 @@ const notFound = (path: string): Response =>
56
63
  { status: 404, headers: { 'content-type': 'application/json' } },
57
64
  );
58
65
 
66
+ /**
67
+ * The MCP port is bound already — by another `x mcp serve`, or by the inspector, whose default
68
+ * 9229 this one shares. `metrics-endpoint.ts`'s pattern: an `EADDRINUSE` throw becomes the
69
+ * framework's `X_PORT_IN_USE`, with the invocation one port over.
70
+ */
71
+ export class McpPortInUseError extends UltimateError {
72
+ constructor(input: { port: number }) {
73
+ super({
74
+ code: 'X_PORT_IN_USE',
75
+ cause: `port ${String(input.port)} is already bound, so x mcp serve could not open its HTTP transport`,
76
+ fix: `x mcp serve --transport http --port ${renderFixShellArg(String(neighbouringPort(input.port)), '9230')}`,
77
+ });
78
+ }
79
+ }
80
+
59
81
  /** A running HTTP transport. `stop()` releases the socket AND the host's lazily booted services. */
60
82
  export interface McpHttpServer {
61
83
  readonly result: CommandResult;
@@ -70,7 +92,7 @@ export interface McpHttpServer {
70
92
  * directory outlive `run()` otherwise, and nothing — a test, an embedding caller, or a signal —
71
93
  * could ever release them.
72
94
  */
73
- export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
95
+ export async function startMcpHttp(host: CliMcpServer, port: number): Promise<McpHttpServer> {
74
96
  const token = nanoid(32);
75
97
  const route = mcpHttpRoute({
76
98
  server: host.server,
@@ -83,17 +105,26 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
83
105
  ? { actor: host.caller.actor, scopes: DEV_TOOL_SCOPES }
84
106
  : null,
85
107
  });
86
- const handle = Bun.serve({
87
- port,
88
- hostname: 'localhost',
89
- fetch: (request: Request): Response | Promise<Response> => {
90
- const url = new URL(request.url);
91
- if (request.method !== route.method || url.pathname !== route.path) {
92
- return notFound(route.path);
93
- }
94
- return route.handle(request);
95
- },
96
- });
108
+ let handle: ReturnType<typeof Bun.serve>;
109
+ try {
110
+ handle = Bun.serve({
111
+ port,
112
+ hostname: 'localhost',
113
+ fetch: (request: Request, server): Response | Promise<Response> => {
114
+ const url = new URL(request.url);
115
+ if (request.method !== route.method || url.pathname !== route.path) {
116
+ return notFound(route.path);
117
+ }
118
+ // The socket's peer: failed tokens are metered per address, and only the server knows it.
119
+ return route.handle(request, { address: server.requestIP(request)?.address });
120
+ },
121
+ });
122
+ } catch (error) {
123
+ // The host booted before the bind; a refusal that left it open would leak its database.
124
+ await host.close();
125
+ if (isAddressInUse(error)) throw new McpPortInUseError({ port });
126
+ throw error;
127
+ }
97
128
  // Announces the socket as this process's own, so a caller on it is never mistaken for egress.
98
129
  const stopListening = markListening(handle.url.origin);
99
130
  const url = `${handle.url.origin}${route.path}`;
@@ -179,7 +210,7 @@ export const mcpCommand: CliCommand = {
179
210
  // starts is what releases the socket and the database. Registering a shutdown hook and
180
211
  // returning was the older shape — nothing installed a signal handler, so the hook was never
181
212
  // reached and the exit code closed the socket the line above had just announced.
182
- const served = startMcpHttp(host, port);
213
+ const served = await startMcpHttp(host, port);
183
214
  return { ...served.result, hold: holdUntilShutdown('mcp', () => served.stop()) };
184
215
  },
185
216
  };
@@ -3,8 +3,8 @@
3
3
  // does not exist", which is false and sends an agent looking for a typo. X_NOT_IMPLEMENTED plus a
4
4
  // fix naming the closest shipped command says the true thing, and `x help` lists them honestly.
5
5
 
6
+ import { NotImplementedError } from '@ultimat3/core';
6
7
  import type { CliCommand } from './command';
7
- import { CliNotImplementedError } from './errors';
8
8
  import type { CommandResult } from './output';
9
9
  import type { CommandSpec } from './parse';
10
10
 
@@ -134,18 +134,28 @@ export const PLANNED_SUBCOMMANDS: readonly PlannedSubcommand[] = [
134
134
  * the error rather than throwing it, because a `run` that throws synchronously escapes the
135
135
  * promise its signature promises — the caller does `throw plannedSubcommand(...)`.
136
136
  */
137
- export function plannedSubcommand(command: string, subcommand: string): CliNotImplementedError {
137
+ export function plannedSubcommand(command: string, subcommand: string): NotImplementedError {
138
138
  const planned = PLANNED_SUBCOMMANDS.find(
139
139
  (entry) => entry.command === command && entry.subcommand === subcommand,
140
140
  );
141
- return new CliNotImplementedError({
142
- feature: `x ${command} ${subcommand}`,
141
+ return new NotImplementedError({
142
+ cause: `x ${command} ${subcommand} is not implemented in this build`,
143
143
  // `x help <command>`, not `x <command> --help`: for a command that declares subcommands and no
144
144
  // default, the latter is refused by the parser before help is ever rendered.
145
145
  fix: planned?.fix ?? `x help ${command}`,
146
146
  });
147
147
  }
148
148
 
149
+ /**
150
+ * What every invocation of a planned command answers — its `run`, and the dispatcher when the
151
+ * parser refused a flag before any `run` was reached. One builder, so the two cannot drift.
152
+ */
153
+ export const plannedCommandError = (planned: PlannedCommand): NotImplementedError =>
154
+ new NotImplementedError({
155
+ cause: `x ${planned.name} is not implemented in this build`,
156
+ fix: planned.fix,
157
+ });
158
+
149
159
  const specFor = (planned: PlannedCommand): CommandSpec => ({
150
160
  name: planned.name,
151
161
  summary: `${planned.summary} (planned)`,
@@ -167,10 +177,7 @@ const toCommand = (planned: PlannedCommand): CliCommand => ({
167
177
  // `async` is load-bearing: a synchronous throw would escape every caller that awaits the
168
178
  // promise this signature promises, including the dispatcher's own error path.
169
179
  async run(): Promise<CommandResult> {
170
- throw new CliNotImplementedError({
171
- feature: `x ${planned.name}`,
172
- fix: planned.fix,
173
- });
180
+ throw plannedCommandError(planned);
174
181
  },
175
182
  });
176
183
 
package/src/cmd-routes.ts CHANGED
@@ -10,7 +10,7 @@ import { loadApp } from './app-load';
10
10
  import { requireAppRoot } from './app-root';
11
11
  import { routesSpec } from './cmd-routes-spec';
12
12
  import type { CliCommand, CommandContext } from './command';
13
- import { BadFlagError } from './errors';
13
+ import { BadFlagError, UnknownCommandError } from './errors';
14
14
  import { msg } from './messages';
15
15
  import type { CommandResult, JsonValue } from './output';
16
16
  import { flagString } from './parse';
@@ -52,7 +52,7 @@ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
52
52
  render: route.mode,
53
53
  hydrate: route.hydrate,
54
54
  offline: route.offline,
55
- budget: { js: route.budgetJs, lcp: route.budgetLcp },
55
+ budget: { js: route.budgetJs },
56
56
  // Absent on a file route, never `null`: only a mounted route has the fact.
57
57
  ...(route.mount === undefined
58
58
  ? {}
@@ -83,6 +83,16 @@ export const routesCommand: CliCommand = {
83
83
  spec: routesSpec,
84
84
  async run(ctx: CommandContext): Promise<CommandResult> {
85
85
  const root = requireAppRoot('routes', ctx.cwd).dir;
86
+ // No positional: `x routes list --json` ran only because a stray word was ignored, which is
87
+ // how a retired spelling stayed in the route-miss fix lines. Refused before the app loads.
88
+ const stray = ctx.args.positionals[0];
89
+ if (stray !== undefined) {
90
+ throw new UnknownCommandError({
91
+ path: `routes ${stray}`,
92
+ known: ['routes'],
93
+ suggestion: 'routes --json',
94
+ });
95
+ }
86
96
  // Read before the app is loaded: a typo must not cost a boot to report, the rule `x mcp`'s
87
97
  // `--transport` already follows.
88
98
  const surface = readSurfaceFilter(flagString(ctx.args, 'surface'));
@@ -25,6 +25,7 @@ import {
25
25
  SecretsPlaintextInvalidError,
26
26
  secretsFileExists,
27
27
  serializeSecretValues,
28
+ UltimateError,
28
29
  writeMasterKeyFile,
29
30
  writeSecretsFile,
30
31
  } from '@ultimat3/core';
@@ -33,12 +34,7 @@ import { requireAppRoot } from './app-root';
33
34
  import { appSecrets, dropRetired, retire, retiredKeyIds } from './cmd-secrets-ring';
34
35
  import { secretsSpec } from './cmd-secrets-spec';
35
36
  import type { CliCommand, CommandContext } from './command';
36
- import {
37
- MissingPositionalError,
38
- SecretsEditFailedError,
39
- SecretsEditorMissingError,
40
- SecretsExistsError,
41
- } from './errors';
37
+ import { MissingPositionalError } from './errors';
42
38
  import { msg } from './messages';
43
39
  import type { CommandResult, JsonValue } from './output';
44
40
  import { flagString } from './parse';
@@ -46,6 +42,51 @@ import { recoverRotation, rotateMasterKey } from './secrets-rotation';
46
42
  import { shredOnSignal } from './signal-shred';
47
43
  import { renderTable } from './table';
48
44
 
45
+ /**
46
+ * `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
47
+ * guessing one and opening a decrypted file in it is the last place a surprise belongs.
48
+ */
49
+ export class SecretsEditorMissingError extends UltimateError {
50
+ constructor(input: { vars: readonly string[] }) {
51
+ super({
52
+ code: 'X_SECRETS_EDITOR_MISSING',
53
+ cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
54
+ fix: 'EDITOR=nano x secrets edit',
55
+ });
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
61
+ * and the committed file is left exactly as it was: resealing a buffer whose editor failed would
62
+ * commit whatever half-written state the crash left behind.
63
+ */
64
+ export class SecretsEditFailedError extends UltimateError {
65
+ constructor(input: { editor: string; code: number }) {
66
+ super({
67
+ code: 'X_SECRETS_EDIT_FAILED',
68
+ cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
69
+ fix: 'x secrets edit',
70
+ });
71
+ }
72
+ }
73
+
74
+ /**
75
+ * `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
76
+ * package's own code for exactly that, and a second name for "a generator would clobber something"
77
+ * is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
78
+ * the committed file it opens is then ciphertext nobody can read again.
79
+ */
80
+ export class SecretsExistsError extends UltimateError {
81
+ constructor(input: { path: string; fix: string }) {
82
+ super({
83
+ code: 'X_GENERATE_CONFLICT',
84
+ cause: `${input.path} already exists, and x secrets init would replace it`,
85
+ fix: input.fix,
86
+ });
87
+ }
88
+ }
89
+
49
90
  export { SECRETS_SUBCOMMANDS } from './cmd-secrets-spec';
50
91
 
51
92
  /** In order of precedence. `VISUAL` outranks `EDITOR` by POSIX convention on an interactive tty. */
@@ -5,6 +5,7 @@
5
5
 
6
6
  // why: no Bun native joins a path.
7
7
  import { join, relative } from 'node:path';
8
+ import { escapeHtml } from '@ultimat3/core';
8
9
  import { routeEntries } from '@ultimat3/render';
9
10
  import { loadApp } from './app-load';
10
11
  import type { ShotRun } from './cmd-shot';
@@ -94,9 +95,6 @@ export interface MatrixShot {
94
95
  readonly image: string;
95
96
  }
96
97
 
97
- const escapeHtml = (text: string): string =>
98
- text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
99
-
100
98
  /**
101
99
  * The contact sheet. System colours only (`Canvas`, `CanvasText`, `Mark`): a generated review page
102
100
  * has no design tokens to read, and a literal colour would be the one this repo refuses.
package/src/cmd-shot.ts CHANGED
@@ -339,6 +339,14 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
339
339
  }
340
340
  }
341
341
 
342
+ /**
343
+ * Where the artifacts land. A relative `--out` is a path the caller typed from where they stand,
344
+ * so it resolves against the cwd — `x build --out`'s rule. Against the app root it landed
345
+ * somewhere else whenever `x shot` ran from `apps/web`.
346
+ */
347
+ export const shotOutDir = (cwd: string, out: string | undefined, fallback: string): string =>
348
+ out === undefined ? fallback : resolve(cwd, out);
349
+
342
350
  export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
343
351
  ok: artifacts.verdict.ok,
344
352
  command: 'shot',
@@ -426,7 +434,8 @@ export const shotCommand: CliCommand = {
426
434
  const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
427
435
  const shared = {
428
436
  root,
429
- ...(out === undefined ? {} : { out }),
437
+ // Absolute here, so the island sweep's own `resolve(root, out)` keeps the caller's path.
438
+ ...(out === undefined ? {} : { out: shotOutDir(ctx.cwd, out, root) }),
430
439
  settleMs,
431
440
  timeoutMs,
432
441
  ...(executablePath === undefined ? {} : { executablePath }),
@@ -465,7 +474,7 @@ export const shotCommand: CliCommand = {
465
474
  defaultLocale: app.defaultLocale,
466
475
  ...(theme === undefined ? {} : { themes: [theme] }),
467
476
  });
468
- const outDir = out === undefined ? join(root, MATRIX_DIR) : resolve(root, out);
477
+ const outDir = shotOutDir(ctx.cwd, out, join(root, MATRIX_DIR));
469
478
  return runShotMatrix({ cells, outDir, boot, shoot: runShot, base });
470
479
  }
471
480
  const shown = locale ?? app.defaultLocale;
@@ -474,7 +483,7 @@ export const shotCommand: CliCommand = {
474
483
  await runShot({
475
484
  ...base,
476
485
  route: path,
477
- outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(path)) : resolve(root, out),
486
+ outDir: shotOutDir(ctx.cwd, out, join(root, SHOT_DIR, shotSlug(path))),
478
487
  boot,
479
488
  // Pinned whether or not `--locale` was given: the default locale, never the box's own.
480
489
  acceptLanguage: shown,
package/src/cmd-tasks.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { nearestName, systemClock } from '@ultimat3/core';
8
8
  import type { JobDriver, TaskFire, TaskHandle } from '@ultimat3/jobs';
9
9
  import type { CronPhrases } from '@ultimat3/time';
10
+ import { fromEpochMs, isoInZone } from '@ultimat3/time';
10
11
  import { loadApp } from './app-load';
11
12
  import { requireAppRoot } from './app-root';
12
13
  import { tasksSpec } from './cmd-tasks-spec';
@@ -19,7 +20,6 @@ import { flagString } from './parse';
19
20
  import { renderTable } from './table';
20
21
  import {
21
22
  findTaskHandle,
22
- isoInZone,
23
23
  knownTaskNames,
24
24
  listTaskFacts,
25
25
  parseCountFlag,
@@ -58,7 +58,7 @@ const lastFireOf = (fires: ReadonlyMap<string, TaskFire>, name: string, tz: stri
58
58
  if (fire === undefined) return NEVER_FIRED;
59
59
  return {
60
60
  lastMs: fire.occurrenceMs,
61
- last: isoInZone(fire.occurrenceMs, tz),
61
+ last: isoInZone(fromEpochMs(fire.occurrenceMs), tz),
62
62
  lastFiredAtMs: fire.firedAt,
63
63
  };
64
64
  };
@@ -10,7 +10,7 @@ export const testSpec: CommandSpec = {
10
10
  name: 'test',
11
11
  summary:
12
12
  'run one test type — or the whole suite — across N workers, one isolated database per worker',
13
- usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--isolate] [--json] [-- <bun test flags>]`,
13
+ usage: `x test [${TEST_TYPES.join('|')}] [--filter path[,path…]] [--allow-empty] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--isolate] [--json] [-- <bun test flags>]`,
14
14
  positionalChoices: TEST_TYPES,
15
15
  // The one command that hands a tail to another tool — `bun test` — and the reason
16
16
  // `CommandSpec.passthrough` exists: `x test unit -- --coverage --bail` parsed both flags and
@@ -28,12 +28,6 @@ export const testSpec: CommandSpec = {
28
28
  summary:
29
29
  'a fresh global per test file (bun test --isolate) — off by default since 22.7; x.verify.json "isolate": true makes it the repo default',
30
30
  },
31
- {
32
- name: 'worker',
33
- type: 'string',
34
- summary:
35
- 'run only shard I of an N-way split of the selection, serially — one CI job\u2019s share',
36
- },
37
31
  {
38
32
  name: 'filter',
39
33
  type: 'string',
package/src/cmd-test.ts CHANGED
@@ -40,22 +40,20 @@ import type { TestType } from './verify-tests';
40
40
  import { TEST_TYPES } from './verify-tests';
41
41
 
42
42
  /**
43
- * `--workers` and `--shard`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while
44
- * `cmd-verify.ts`'s own comment claimed `x test --workers` refused the same values `x verify`
45
- * does — so the two commands disagreed about the same flag. One reader now answers for both.
43
+ * `--workers`. `Number.parseInt` alone accepted `4abc` and `4.9` as four, while `cmd-verify.ts`'s
44
+ * own comment claimed `x test --workers` refused the same values `x verify` does — so the two
45
+ * commands disagreed about the same flag. One reader now answers for both.
46
46
  */
47
- const readIndex = (args: ParsedArgs, name: string, min: number): number | undefined =>
47
+ const readWorkers = (args: ParsedArgs): number | undefined =>
48
48
  readIntFlag(args, {
49
- name,
49
+ name: 'workers',
50
50
  command: 'test',
51
- min,
51
+ min: 1,
52
52
  // The ceiling the summary already claimed and the reader never enforced: `--workers 5000` was
53
53
  // accepted and the run clamps only to the file count, which is one Bun worker per test FILE,
54
- // each with the framework module graph and a cloned database. `--worker` is an index into an
55
- // N-way split, so the same bound holds it (the exact upper index is `workers - 1`, refused a
56
- // line below by the check that knows the real width).
54
+ // each with the framework module graph and a cloned database.
57
55
  max: WORKER_CEILING,
58
- example: `x test --${name} ${Math.max(min, 1)}`,
56
+ example: 'x test --workers 1',
59
57
  });
60
58
 
61
59
  /**
@@ -168,29 +166,17 @@ export const testCommand: CliCommand = {
168
166
  });
169
167
  }
170
168
  const files = sample === undefined ? selected : sampleFiles(selected, sample);
171
- const explicit = readIndex(ctx.args, 'workers', 1);
169
+ const explicit = readWorkers(ctx.args);
172
170
  const plan =
173
171
  explicit === undefined ? workerPlan(availableCpus(), totalMemory(), ctx.env) : undefined;
174
172
  const requested = explicit ?? (plan as WorkerPlan).workers;
175
173
  const floor = await readVerifyFloor(ctx.cwd);
176
174
  const isolate = flagBool(ctx.args, 'isolate') || floor?.isolate === true;
177
- // The width a `--worker` index is judged against, and nothing else: which files really run one
178
- // at a time is `test-passes.ts`, off the FILES rather than off the positional. This line read
179
- // the positional alone until 2026-09 and the comment here claimed it made a serial type serial
180
- // — true for `x test live`, false for the bare `x test --workers 8` that selects every type,
181
- // which ran the same `live` and `e2e` files eight at a time. It stays because a `--worker` run
182
- // is one process per CI JOB: sharding a serial type across N of them is the cluster-wide race
183
- // in a second disguise, and refusing the index is where that is caught.
175
+ // A serial type is one worker whatever `--workers` says. `test-passes.ts` makes the same call
176
+ // off the FILES, which is what decides which files really run one at a time; this keeps the
177
+ // width the run reports and reproduces with the one it ran at.
184
178
  const ceiling = type !== undefined && SERIAL_TYPES.includes(type) ? 1 : files.length;
185
179
  const workers = Math.max(1, Math.min(requested, ceiling));
186
- const only = readIndex(ctx.args, 'worker', 0);
187
- if (only !== undefined && only >= workers) {
188
- throw new BadFlagError({
189
- flag: 'worker',
190
- command: 'test',
191
- reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
192
- });
193
- }
194
180
  const result = await runShards({
195
181
  root: ctx.cwd,
196
182
  runner: ctx.runner,
@@ -201,11 +187,9 @@ export const testCommand: CliCommand = {
201
187
  // A default-width run leases its workers from the machine pool, so a second `x test` or
202
188
  // gate on the same box runs narrower instead of doubling its memory.
203
189
  ...(plan === undefined ? {} : withLease(machineLease(plan.workers, ctx.env))),
204
- ...(only === undefined ? {} : { only }),
205
190
  ...(filter === undefined ? {} : { filter }),
206
191
  ...(type === undefined ? {} : { type }),
207
- // `kept` is the corpus the split saw; a `--worker` rerun must name it, not its own shard.
208
- // `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
192
+ // `kept` is the corpus the run saw. `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
209
193
  // set, and reporting the whole tree as its total would name a corpus no run ever had.
210
194
  ...(sample === undefined ? {} : { sample: { kept: files.length, total: selected.length } }),
211
195
  // The fourth input to the split. Without it a failing shard's `fix:` re-splits the whole
package/src/cmd-verify.ts CHANGED
@@ -20,14 +20,13 @@ import type { ParsedArgs } from './parse';
20
20
  import { flagBool, flagString } from './parse';
21
21
  import { WORKER_CEILING, WORKER_FLOOR } from './test-workers';
22
22
  import { VERIFY_STEPS } from './verify-checks';
23
- import { VerifyMergeInputError, VerifyShardInvalidError } from './verify-errors';
24
23
  import { readVerifyFloor } from './verify-floor';
25
24
  import type { VerifyPart } from './verify-merge';
26
- import { coverageRiders, mergeParts, parsePart } from './verify-merge';
25
+ import { coverageRiders, mergeParts, parsePart, VerifyMergeInputError } from './verify-merge';
27
26
  import { stepStream } from './verify-progress';
28
27
  import { runVerify } from './verify-run';
29
28
  import type { ShardSpec, Timings } from './verify-shard';
30
- import { assertShardable, parseShard, readTimings } from './verify-shard';
29
+ import { assertShardable, parseShard, readTimings, VerifyShardInvalidError } from './verify-shard';
31
30
  import type { VerifyStepName } from './verify-step';
32
31
  import { VERIFY_STEP_NAMES } from './verify-step';
33
32
  import { writeErrorLine } from './write-line';
@@ -150,8 +149,9 @@ export const readOnlySteps = (args: ParsedArgs): readonly VerifyStepName[] | und
150
149
  if (unknown.length === 0) {
151
150
  return VERIFY_STEP_NAMES.filter((name) => items.includes(name));
152
151
  }
152
+ // An empty item corrects to nothing — never to whichever step is nearest to ''.
153
153
  const corrected = items.map((item) =>
154
- isStepName(item) ? item : nearestName(item, VERIFY_STEP_NAMES),
154
+ isStepName(item) ? item : item === '' ? undefined : nearestName(item, VERIFY_STEP_NAMES),
155
155
  );
156
156
  const fix = corrected.every((item) => item !== undefined)
157
157
  ? `x verify --only ${renderFixShellArg([...new Set(corrected)].join(','), '<step,step>')} --json`
@@ -1,8 +1,9 @@
1
- // Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL`, read off the
2
- // live queries' DECLARED `subscribes:` — and the refusal for a declared name no entity's table
3
- // matches. This tier is the only one holding both registries, so it is the only one that can ask.
1
+ // Single responsibility: which tables `x db gen` must grant `REPLICA IDENTITY FULL` — the live
2
+ // queries' DECLARED `subscribes:` plus the `records` tables of every channel with params — and the
3
+ // refusal for a declared name no entity's table matches. Only this tier holds all the registries.
4
4
 
5
5
  import { UltimateError } from '@ultimat3/core';
6
+ import { paramsChannelTables } from '@ultimat3/realtime/server';
6
7
 
7
8
  /**
8
9
  * The two fields this reads, and nothing else. Structurally satisfied by `QueryDescriptor` (whose
@@ -63,12 +64,19 @@ export class QuerySubscribesUnknownError extends UltimateError {
63
64
  * Every name is checked against `tables` BEFORE any of them is returned: a run that emitted the
64
65
  * good half and refused afterwards would leave an author with a migration that is right for one
65
66
  * table and silently absent for the other.
67
+ *
68
+ * `channelTables` defaults to what the declared channels need (`paramsChannelTables()`): a channel
69
+ * with params routes a DELETE to the topic the OLD row names, and under the default identity that
70
+ * image is the key alone — no topic, no `remove`, and members keep the deleted record. They are
71
+ * derived from entity projections rather than typed by an author, so one outside `tables` is
72
+ * dropped, not refused: there is no declaration to send anyone back to.
66
73
  */
67
74
  export function replicaIdentityTables(
68
75
  queries: readonly SubscribingQuery[],
69
76
  tables: ReadonlySet<string>,
77
+ channelTables: readonly string[] = paramsChannelTables(),
70
78
  ): readonly string[] {
71
- const wanted = new Set<string>();
79
+ const wanted = new Set<string>(channelTables.filter((table) => tables.has(table)));
72
80
  for (const query of queries) {
73
81
  for (const table of query.subscribes ?? []) {
74
82
  if (!tables.has(table)) {
@@ -0,0 +1,76 @@
1
+ // The doors in front of `/_x` that a browser can knock on. `x dev` binds loopback, but a page the
2
+ // developer has open can still reach it: by DNS rebinding (a hostile name that resolves to
3
+ // 127.0.0.1, read back same-origin) and by a cross-site request to the SQL panel.
4
+
5
+ import { proveSameOrigin, renderFixShellArg, UltimateError } from '@ultimat3/core';
6
+ import type { UltimateRequest } from '@ultimat3/http';
7
+ import { csrfBlocked, HttpError, json as jsonResponse, selfOrigin } from '@ultimat3/http';
8
+ import { t } from '@ultimat3/schema';
9
+ import { isLoopbackHostname } from './loopback-host';
10
+
11
+ /** A `/_x` request named a Host that is not this machine. Never echoes the Host it was sent. */
12
+ export class DevHostRefusedError extends UltimateError {
13
+ constructor(input: { devUrl: string }) {
14
+ super({
15
+ code: 'X_DEV_HOST_REFUSED',
16
+ cause:
17
+ 'a /_x request named a Host that is not localhost, a *.localhost name or a loopback address — the shape of a DNS-rebinding page reading the dev dashboard',
18
+ fix: `curl -sS -H 'accept: application/json' ${renderFixShellArg(`${input.devUrl}/_x`, 'http://localhost:3000/_x')}`,
19
+ });
20
+ }
21
+ }
22
+
23
+ /** The Host as sent, else the one the URL was built from — Bun builds it from the header. */
24
+ const hostnameOf = (request: UltimateRequest): string | undefined => {
25
+ const header = request.header('host') ?? request.url.host;
26
+ return URL.parse(`http://${header}`)?.hostname;
27
+ };
28
+
29
+ /**
30
+ * `421 Misdirected Request` for a Host that is not this machine, on every `/_x` route. The status
31
+ * says "not this server", which is the truth: a rebound name was never meant to reach it.
32
+ */
33
+ export function hostRefusal(request: UltimateRequest, devUrl: string): Response | undefined {
34
+ const hostname = hostnameOf(request);
35
+ if (hostname !== undefined && isLoopbackHostname(hostname)) return undefined;
36
+ const error = new DevHostRefusedError({ devUrl });
37
+ return jsonResponse(
38
+ { ok: false, error: { code: error.code, cause: error.cause, fix: error.fix } },
39
+ { status: 421 },
40
+ );
41
+ }
42
+
43
+ /**
44
+ * A statement runs on a POST that came from this origin — core's `proveSameOrigin`, the rule the
45
+ * app's own writes and the sync upgrade answer to, asked HERE rather than left to the pipeline's
46
+ * CSRF stage: an app with `csrf: { mode: 'off' }` must not switch the dev database's door off with
47
+ * it. Neither header present is `curl`, as `checkCsrf` reads it: no page can drive that client.
48
+ */
49
+ export function assertSameOrigin(request: UltimateRequest): void {
50
+ const origin = request.header('origin');
51
+ const secFetchSite = request.header('sec-fetch-site');
52
+ if (origin === null && secFetchSite === null) return;
53
+ const verdict = proveSameOrigin({
54
+ selfOrigins: [selfOrigin(request.url, request.ctx.https)],
55
+ origin,
56
+ secFetchSite,
57
+ listed: () => false,
58
+ listName: 'no list — /_x admits its own origin only',
59
+ });
60
+ if (!verdict.ok) throw csrfBlocked(request.pathname, verdict.reason, request.ctx.ip);
61
+ }
62
+
63
+ /** The SQL panel's one input, from a form or a JSON body — `request.body` parses either. */
64
+ export const SQL_BODY = t.object({ sql: t.string });
65
+
66
+ /**
67
+ * A statement on the query string is refused rather than run: a GET is what `<img src>` and a
68
+ * prefetch send cross-site with nobody's consent. The fix is the POST that does run it.
69
+ */
70
+ export const sqlOnGet = (devUrl: string): HttpError =>
71
+ new HttpError({
72
+ code: 'X_METHOD_NOT_ALLOWED',
73
+ cause:
74
+ '/_x/db runs a statement on a same-origin POST only — a GET carrying ?sql= is what a cross-site page can send',
75
+ fix: `curl -sS -H 'accept: application/json' --data-urlencode 'sql=select 1' ${renderFixShellArg(`${devUrl}/_x/db`, 'http://localhost:3000/_x/db')}`,
76
+ });