@ultimat3/cli 24.0.0 → 25.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 (242) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
package/src/cmd-dev.ts CHANGED
@@ -1,372 +1,32 @@
1
- // `x dev` — the app, booted. Every role in one Bun process, embedded Postgres/events/storage
2
- // started for real, the app's own modules loaded into the framework's registries, and the route
3
- // table those registries hold served over HTTP. `@ultimat3/admin`'s `/_x` dashboard is mounted
4
- // alongside it — mounted, never re-implemented — so an agent can introspect the running app.
1
+ // `x dev` — the app, booted (`dev-boot.ts`), reported and held. Every role in one Bun process,
2
+ // embedded Postgres/events/storage started for real, and `@ultimat3/admin`'s `/_x` dashboard
3
+ // mounted alongside the app — mounted, never re-implemented — so an agent can introspect it.
5
4
  // No Docker, no env setup: an unset variable means the embedded default.
6
5
 
7
6
  import { join } from 'node:path';
8
- import { devShellStyle } from '@ultimat3/admin/dev';
9
- import type { Role } from '@ultimat3/core';
10
- import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
11
- import { setStatementObserver } from '@ultimat3/db';
12
- import type { OverlayNotice, RequestContext } from '@ultimat3/http';
13
- import { asCtx } from '@ultimat3/http';
14
- import type { Manifest } from '@ultimat3/manifest';
7
+ import { METRICS_PATH } from '@ultimat3/core';
15
8
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
16
- import { loadSignInPath } from './app-auth';
17
- import { appManifest } from './app-manifest';
18
- import type { StalePin } from './app-reload-graph';
19
- import { enableReloadTracking, takeStalePins } from './app-reload-graph';
20
9
  import { requireAppRoot } from './app-root';
21
- import { loadAppRuntime } from './app-runtime';
22
10
  import { devSpec } from './cmd-dev-spec';
23
11
  import type { CliCommand, CommandContext } from './command';
24
- import type { DevDashboardInput, DevStatus } from './dev-dashboard';
25
- import { devPanels } from './dev-dashboard';
26
- import { declareDevEnvironment, needsDevEnvironmentDeclaration } from './dev-environment';
12
+ import { startDev } from './dev-boot';
27
13
  import { clearLock, preflight, writeLock } from './dev-lock';
28
- import { createStatementLedger } from './dev-n-plus-one';
29
14
  import { devPortFor } from './dev-port';
30
- import { coalesceReloads } from './dev-reload';
31
- import { devRouteTable } from './dev-route-table';
32
- import { childRestart, restartFinding } from './dev-supervisor';
33
- import { createTraceRecorder } from './dev-traces';
34
- import { watchTree } from './dev-watch-tree';
15
+ import { childRestart } from './dev-supervisor';
35
16
  import { holdUntilShutdown } from './hold';
36
- import type { IslandBundle } from './island-bundle';
37
- import { buildIslands } from './island-bundle';
38
- import { FRAME_STYLE } from './island-harness';
39
17
  import { msg } from './messages';
40
- import type { CommandResult, Finding } from './output';
41
- import { findingFrom } from './output';
18
+ import type { CommandResult } from './output';
42
19
  import { flagString } from './parse';
43
- import type { RunningRoles } from './role-start';
44
- import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './role-start';
45
- import type { DevServices } from './runtime-bindings';
46
- import {
47
- describeServices,
48
- reportedUrls,
49
- resolveServices,
50
- withRealtimeEvents,
51
- } from './runtime-bindings';
52
- import { attachedIsr } from './runtime-isr';
20
+ import { DEV_BINDING, selectRoles } from './role-start';
21
+ import { describeServices, reportedUrls, resolveServices } from './runtime-bindings';
53
22
  import { liveFeedLabel } from './runtime-live-feed';
54
- import type { RuntimeOverrides } from './runtime-overrides';
55
- import { replicaOverrides } from './runtime-replica';
56
- import type { RunningServices } from './runtime-services';
57
- import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './runtime-services';
58
- import { metricsPortFor, releaseBoot } from './serve';
59
- import { loopFacts, loopFinding, loopNotice } from './statement-loop';
23
+ import { cdnLabel, describeCdn, describeMail, mailLabel } from './runtime-services';
24
+ import { writeLine } from './write-line';
60
25
 
61
- const _DEFAULT_PORT = 3000;
62
-
63
- export interface DevServer {
64
- readonly url: string;
65
- readonly services: DevServices;
66
- readonly roles: readonly Role[];
67
- /**
68
- * `BUILD_ID` when stamped — the id every response carries. Otherwise the manifest as it stands
69
- * now, so a reload that registers a new route moves it.
70
- */
71
- readonly buildId: string;
72
- /**
73
- * Modules that would not import, primitives that would not register, reloads that would not
74
- * build — and the statement loops this process has counted so far, which is what puts an N+1 in
75
- * `x dev`'s own output and in `--json` without a channel of its own.
76
- */
77
- readonly findings: readonly Finding[];
78
- readonly running: RunningRoles;
79
- readonly runtime: RunningServices;
80
- /** Panel keys `/_x` mounted, in tab order. Reported so `--json` names what is reachable. */
81
- readonly panels: readonly string[];
82
- /** `POST <path>` of the app's default MCP endpoint, or `null` when nothing was mounted. */
83
- readonly mcp: string | null;
84
- /** `POST <path>` of every mounted MCP endpoint, default first — `[]` when none was. */
85
- readonly mcpPaths: readonly string[];
86
- stop(): Promise<void>;
87
- }
88
-
89
- interface DevState {
90
- manifest: Manifest;
91
- reloads: number;
92
- /** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
93
- reloadFinding: Finding | undefined;
94
- /**
95
- * Modules that would not import and primitives that would not register, as of the LAST scan —
96
- * the boot's, then each rebuild's. A page saved with a syntax error is a finding here until the
97
- * save that fixes it, and the boot's list alone would never show it.
98
- */
99
- appFindings: readonly Finding[];
100
- /**
101
- * The client entries, rebuilt on the same tick as the manifest. An island is the one module this
102
- * process never imports, so a fresh `Bun.build` is the whole of its reload. The route module
103
- * beside it is re-imported by that same scan (`app-load.ts`) — the two are one generation, or
104
- * a save serves a new island under an old page, which is what it did until 2026-09-07.
105
- */
106
- islands: IslandBundle;
107
- }
108
-
109
- export interface StartDevOptions {
110
- readonly root: string;
111
- readonly port: number;
112
- readonly env: Readonly<Record<string, string | undefined>>;
113
- readonly roles?: readonly Role[];
114
- readonly onReload?: (file: string, durationMs: number) => void;
115
- /**
116
- * A save reached a module that defines a primitive, so this process cannot serve it
117
- * (`takeStalePins`). Given, it is called INSTEAD of `onReload` — `x dev`'s child drains and exits
118
- * for its supervisor to boot a fresh one. Absent, the save is `X_DEV_RESTART_REQUIRED` on `/_x`.
119
- */
120
- readonly onRestart?: (pins: readonly StalePin[]) => void;
121
- }
122
-
123
- /**
124
- * The environment `devDashboard` refuses to mount in. Spread conditionally rather than passed as
125
- * `undefined`: `exactOptionalPropertyTypes` makes "absent" and "explicitly undefined" different
126
- * answers, and only the absent one lets the dashboard read the process environment itself.
127
- */
128
- const envOf = (env: StartDevOptions['env']): { env?: string } => {
129
- const value = env['NODE_ENV'] ?? env['X_ENV'];
130
- return value === undefined ? {} : { env: value };
131
- };
132
-
133
- /**
134
- * Boot order is the production order: services first, then the app's modules (importing them IS
135
- * the registration), then the roles that serve what those modules registered. A module that
136
- * fails to import becomes a finding rather than a dead process — the point of the dev loop is to
137
- * still be reachable while something is broken.
138
- */
139
- export async function startDev(options: StartDevOptions): Promise<DevServer> {
140
- // EVERY boot: a scratch server (`x shot`, `ui.shot`) boots here too, and without this a
141
- // fail-closed dev actor installs nothing — the picture is of a 401. Idempotent.
142
- declareDevEnvironment(options.env);
143
- // Before the first scan: the watcher's rescans evict a saved module AND everything importing it,
144
- // and that needs the import graph recorded as each module is first loaded (`app-reload-graph.ts`).
145
- enableReloadTracking(true);
146
- // The declaration lands on `process.env`; the env this boot passes on must carry it too, or every
147
- // service resolved from `options.env` still reads no `ULTIMATE_ENV`.
148
- const env = needsDevEnvironmentDeclaration(options.env)
149
- ? { ...options.env, ULTIMATE_ENV: 'development' }
150
- : options.env;
151
- const resolved = resolveServices(options.root, env);
152
- // The app's `apps/<app>/runtime.ts`, read ONCE and handed to `startServices` exactly as
153
- // `serve.ts`'s `withAppRuntime` does — so the disks (`storage`), the queue, the mail driver and
154
- // every other override a deployment declares are the ones development runs too. Until 22.4 this
155
- // boot passed nothing here and built its own embedded disk, so an app's `/_storage` routes and its
156
- // actions wrote to two different places in `x dev` and to one in production.
157
- const appRuntime = await loadAppRuntime(options.root);
158
- const runtime: RunningServices = await startServices(resolved, env, appRuntime);
159
- // The events binding the boot line reports is the bus the runtime chose, read off it.
160
- const services = withRealtimeEvents(resolved, env, runtime.realtime);
161
- // `serve.ts`'s `releaseBoot` shape: everything acquired from here on is released, newest first,
162
- // if the boot throws — a failed `x dev` left PGlite holding `.x/pgdata` for the retry to meet.
163
- const acquired: (() => void | Promise<void>)[] = [() => runtime.stop()];
164
- try {
165
- return await bootDev(options, services, runtime, acquired, appRuntime);
166
- } catch (error) {
167
- await releaseBoot(acquired);
168
- throw error;
169
- }
170
- }
171
-
172
- async function bootDev(
173
- options: StartDevOptions,
174
- services: ReturnType<typeof resolveServices>,
175
- runtime: RunningServices,
176
- acquired: (() => void | Promise<void>)[],
177
- appRuntime: RuntimeOverrides | undefined,
178
- ): Promise<DevServer> {
179
- // Installed before the app loads, so a span opened during registration is already recorded.
180
- // Tracing is always on in the framework and free until an exporter is configured; `x dev` is
181
- // what configures one, which is the whole reason `/_x/timeline` has anything to draw.
182
- const traces = createTraceRecorder();
183
- configureTelemetry({ exporter: traces.exporter });
184
- acquired.push(() => {
185
- configureTelemetry({ exporter: noopExporter });
186
- traces.reset();
187
- });
188
- // Installed at the same moment and for the same reason: an observer is the single switch that
189
- // turns statement instrumentation on at all (`@ultimat3/db`'s `observe.ts`), so the timeline's
190
- // SQL rows and the repeat counts arrive together rather than through two toggles. `serve.ts`
191
- // installs neither — a production process pays the one `undefined` branch the seam costs
192
- // uninstalled, and nothing more (axiom 6).
193
- const statements = createStatementLedger();
194
- setStatementObserver(statements.observer);
195
- acquired.push(() => {
196
- setStatementObserver(undefined);
197
- statements.reset();
198
- });
199
- // ONE load at boot, the same call the rebuild below makes: the manifest and the findings are
200
- // two projections of one scan. Until 2026-09-07 this was `loadApp` for the findings and then
201
- // `appManifest` — which loads again — for the manifest, so a save landing between the two put
202
- // `/_x`'s findings and its manifest on different registration states.
203
- const app = await appManifest(options.root);
204
- const state: DevState = {
205
- manifest: app.manifest,
206
- reloads: 0,
207
- reloadFinding: undefined,
208
- appFindings: app.findings,
209
- islands: await buildIslands(options.root),
210
- };
211
- // The manifest's build id is a content hash of every fact below it, so a dev document's
212
- // `x-ultimate-build` header names the exact shape the client was served against. Pinned at
213
- // boot on purpose: the header is handed to the HTTP config and the render modes once, and a
214
- // reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
215
- // divergence between the two is visible rather than silent, and a restart closes it.
216
- // `BUILD_ID` wins when set — `serve.ts`'s rule, so an e2e `deploy.newBuild()` can restart `x dev`
217
- // as a new build with the same sources.
218
- // Stamped, it is ALSO what `server.buildId` answers below: the process serves no other build.
219
- const rawStamp = options.env['BUILD_ID'];
220
- const stamped = rawStamp !== undefined && rawStamp !== '' ? rawStamp : undefined;
221
- const buildId = stamped ?? state.manifest.buildId;
222
-
223
- let server: DevServer;
224
- // Read at request time, never captured at boot: `/_x/services` must report the reload counter
225
- // and the findings as they are now, not as they were when the route table was built.
226
- const dashboard: DevDashboardInput = {
227
- root: options.root,
228
- runtime,
229
- status: (): DevStatus => ({
230
- url: server.url,
231
- services: server.services,
232
- roles: server.roles,
233
- findings: server.findings,
234
- reloads: state.reloads,
235
- }),
236
- traces,
237
- statements,
238
- ...envOf(options.env),
239
- };
240
- const panels = devPanels(dashboard).map((panel) => panel.key);
241
-
242
- // `x dev`'s own, so a reload can empty it (a save otherwise kept a page's first render for its
243
- // ttl); attached, so a tag bust reaches it too. Released with the roles.
244
- const { isr, release: releaseIsr } = attachedIsr({ buildId });
245
- acquired.push(releaseIsr);
246
- const { routes, theme, speculation, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
247
- isr,
248
- root: options.root,
249
- env: options.env,
250
- buildId,
251
- storage: runtime.storage,
252
- dashboard,
253
- islands: () => state.islands,
254
- realtime: runtime.realtime,
255
- rateLimitStore: appRuntime?.rateLimitStore ?? runtime.rateLimitStore,
256
- appRoutes: appRuntime?.routes,
257
- });
258
-
259
- // The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
260
- // `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
261
- // first argument was `undefined` here and an app's middleware reached no development process.
262
- const replicaOverride = replicaOverrides(appRuntime, services.db, options.env);
263
- const running = await startRoles({
264
- roles: options.roles ?? DEV_ROLES,
265
- port: options.port,
266
- // `serve.ts`'s expression, called rather than restated: `METRICS_PORT` was read in the
267
- // container and ignored here, so the scrape port an operator moved was the one port `x dev`
268
- // could not move — and the second `x dev` on a box died binding the hardcoded 9090.
269
- metricsPort: metricsPortFor(options.env, options.port),
270
- buildId,
271
- runtime,
272
- routes,
273
- env: options.env,
274
- // Read from `app.config.ts` rather than threaded through `DevOptions`: `x dev` and `serve.ts`
275
- // must not be able to disagree about where the app's sign-in page is.
276
- signInPath: await loadSignInPath(options.root),
277
- // The same seam `serve.ts` passes: the app's own error page is a FILE under this root.
278
- root: options.root,
279
- // The `/_x` shell, the harness's frame, and the app's own error pages — the inline bodies this
280
- // process serves; the app's surfaces are content-hashed files `'self'` admits.
281
- inlineStyles: [await devShellStyle(), FRAME_STYLE, ...errorStyles],
282
- inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
283
- // The overlay renders this request's own loops under the error it is already showing.
284
- // `serve.ts` boots through the same `startRoles` and passes nothing (axiom 6).
285
- devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
286
- statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
287
- // The read-replica scope, opened per request. Absent for every app that names no
288
- // `DATABASE_REPLICA_URL` — which is every embedded boot by construction, since PGlite has no
289
- // standby — so this key does not exist on a homework app's boot at all.
290
- ...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
291
- });
292
- acquired.push(() => running.stop());
293
-
294
- // One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
295
- // trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
296
- const rebuild = coalesceReloads(
297
- async (file) => {
298
- const started = performance.now();
299
- const [{ manifest, findings }, islands] = await Promise.all([
300
- appManifest(options.root),
301
- buildIslands(options.root),
302
- ]);
303
- state.manifest = manifest;
304
- state.appFindings = findings;
305
- state.islands = islands;
306
- for (const path of isr.store().paths()) isr.store().delete(path);
307
- state.reloads += 1;
308
- // Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
309
- const pins = takeStalePins();
310
- state.reloadFinding =
311
- pins.length > 0 && options.onRestart === undefined
312
- ? restartFinding(options.root, pins)
313
- : undefined;
314
- if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
315
- else options.onReload?.(file, Math.round(performance.now() - started));
316
- },
317
- // Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
318
- // a finding on `/_x`, never an unhandled rejection that takes the dev server down.
319
- (error: unknown, file: string) => {
320
- state.reloadFinding = { ...findingFrom(error), at: file };
321
- },
322
- );
323
- // Watched one directory at a time, so an ignored one costs no descriptor at all — `dev-watch.ts`
324
- // decides which, from the app's own `.gitignore`. A recursive watch on the root registered one
325
- // inotify descriptor per directory in the tree, `.git/`, `node_modules/` and the `.x/` this
326
- // process writes to included, and filtered the events afterwards.
327
- const watcher = watchTree({ root: options.root, onChange: rebuild });
328
-
329
- server = {
330
- url: running.url ?? `http://localhost:${options.port}`,
331
- services,
332
- roles: running.roles,
333
- mcp: mcpPath,
334
- mcpPaths,
335
- get buildId(): string {
336
- return stamped ?? state.manifest.buildId;
337
- },
338
- // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
339
- // loop the last request tripped, not the findings as they were when the route table was built.
340
- // The loops come last and carry their request id, so a boot report reads as a boot report and
341
- // a diagnostic that arrived a minute later reads as one too.
342
- get findings(): readonly Finding[] {
343
- const loops = statements.repeats().map(loopFacts).map(loopFinding);
344
- return state.reloadFinding === undefined
345
- ? [...state.appFindings, ...loops]
346
- : [...state.appFindings, state.reloadFinding, ...loops];
347
- },
348
- running,
349
- runtime,
350
- panels,
351
- async stop() {
352
- watcher.close();
353
- await running.stop();
354
- releaseIsr();
355
- await runtime.stop();
356
- // Released after the roles: a span opened by an in-flight request still has an exporter to
357
- // end into. `configureTelemetry` merges, so handing back the noop is how it is uninstalled —
358
- // leaving it in place would keep every span of the next `startDev` in this process's buffer.
359
- configureTelemetry({ exporter: noopExporter });
360
- traces.reset();
361
- // With the exporter, for the same reason: an in-flight statement is observed by the ledger
362
- // that counted its request. Left installed, the next `startDev`'s statements land in these
363
- // counts — and instrumentation stays on in a process that is no longer a dev server.
364
- setStatementObserver(undefined);
365
- statements.reset();
366
- },
367
- };
368
- return server;
369
- }
26
+ // The boot moved to `dev-boot.ts`; its names stay importable from here, where every caller and the
27
+ // package barrel already reach them.
28
+ export type { DevServer, StartDevOptions } from './dev-boot';
29
+ export { startDev } from './dev-boot';
370
30
 
371
31
  export const devCommand: CliCommand = {
372
32
  spec: devSpec,
@@ -400,8 +60,7 @@ export const devCommand: CliCommand = {
400
60
  roles,
401
61
  env: ctx.env,
402
62
  onReload: (file, durationMs) => {
403
- if (!ctx.args.json)
404
- process.stdout.write(`${msg('cli.dev.hmr', { file, ms: durationMs })}\n`);
63
+ if (!ctx.args.json) writeLine(msg('cli.dev.hmr', { file, ms: durationMs }));
405
64
  },
406
65
  // Supervised, a save this process cannot serve is a drain and an exit for a fresh child.
407
66
  ...restart.options,
@@ -409,6 +68,8 @@ export const devCommand: CliCommand = {
409
68
  release();
410
69
  throw error;
411
70
  });
71
+ // A restart retires this worker before it drains, so a save never cancels a running job.
72
+ restart.adopt(server.running.worker);
412
73
  const result: CommandResult = {
413
74
  ok: server.findings.length === 0,
414
75
  command: 'dev',
package/src/cmd-doctor.ts CHANGED
@@ -4,20 +4,8 @@
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
6
  import { dirname, join, resolve, sep } from 'node:path';
7
- import {
8
- ENV_EXAMPLE_PATH,
9
- ERROR_DOCS_URL,
10
- tryResolveEnvironment,
11
- usesDevCursorSecret,
12
- } from '@ultimat3/core';
13
- import {
14
- checkDb,
15
- createPostgresClient,
16
- PGLITE_FIX,
17
- PGLITE_MISSING,
18
- PGLITE_PACKAGE,
19
- } from '@ultimat3/db';
20
- import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
7
+ import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL } from '@ultimat3/core';
8
+ import { checkDb, PGLITE_FIX, PGLITE_MISSING, PGLITE_PACKAGE, postgresClient } from '@ultimat3/db';
21
9
  import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
22
10
  import { doctorSpec } from './cmd-doctor-spec';
23
11
  import type { CliCommand, CommandContext } from './command';
@@ -31,6 +19,7 @@ import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
31
19
  import type { SealedKeysFact } from './doctor-sealed';
32
20
  import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
33
21
  import { PORT_RANGE, portPairAfter, readIntFlag } from './flag-number';
22
+ import { machineSecretFindings } from './framework-env';
34
23
  import { ICON_SOURCE } from './icon-assets';
35
24
  import { msg } from './messages';
36
25
  import type { CommandResult, Finding } from './output';
@@ -40,6 +29,7 @@ import { portFree } from './port-probe';
40
29
  import { syncPortFor } from './role-sync';
41
30
  import { checkMigrationDrift } from './schema-drift';
42
31
  import { portFromEnv } from './serve-env';
32
+ import { quoteArg } from './shell-quote';
43
33
 
44
34
  /**
45
35
  * The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
@@ -54,17 +44,13 @@ export interface DoctorProbe {
54
44
  readonly port: number;
55
45
  /** `APP_URL` as this environment declares it; `undefined` when unset. */
56
46
  readonly appUrl: string | undefined;
57
- /** True while cursors are signed with the key shipped in the published package. */
58
- readonly devCursorSecret: boolean;
59
47
  /**
60
- * True while the local disk WOULD sign upload grants with the key shipped in the published
61
- * package. Same semantics as `devCursorSecret`, environment only — an app that passes an
62
- * explicit `signingSecret` in `app.config.ts` never consults the env var, so this can read true
63
- * for an app that is fine. The finding is worded as a condition to check, not a certainty.
48
+ * The framework's own secrets this machine lacks — `framework-env.ts`'s
49
+ * `machineSecretFindings`, gated by `namedDeployed`: only an environment NAMED staging or
50
+ * production (an unnamed developer shell owes nothing; `x env check` is the fail-closed answer).
51
+ * The list's own `needed`/`unsafe` decide; doctor reports.
64
52
  */
65
- readonly devStorageSecret: boolean;
66
- /** True when this process believes it is serving real clients. */
67
- readonly production: boolean;
53
+ readonly frameworkFindings: readonly Finding[];
68
54
  exists(relativePath: string): boolean;
69
55
  portFree(port: number): Promise<boolean>;
70
56
  /**
@@ -191,7 +177,7 @@ async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
191
177
  finding(
192
178
  'X_PORT_IN_USE',
193
179
  `port ${entry.port} is already listening, and \`x dev --port ${probe.port}\` binds it for the ${entry.role} role`,
194
- `x dev --port ${portPairAfter(probe.port)}`,
180
+ `x dev --port ${quoteArg(String(portPairAfter(probe.port)))}`,
195
181
  ),
196
182
  );
197
183
  }
@@ -231,41 +217,14 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
231
217
  // `.env.example` is the committed projection of `envSchema`, so the copy lands every
232
218
  // declared key with its default and blank secrets; `x env example` writes it where an app
233
219
  // has none, and `X_ENV_EXAMPLE_DRIFT` is what reports that.
234
- `cp ${ENV_EXAMPLE_PATH} ${ENV_DEVELOPMENT}`,
220
+ `cp ${quoteArg(ENV_EXAMPLE_PATH)} ${quoteArg(ENV_DEVELOPMENT)}`,
235
221
  ENV_DEVELOPMENT,
236
222
  ),
237
223
  );
238
224
  }
239
- // Production only, and the gate is the point: every development environment signs with the
240
- // shipped key on purpose — that is what lets `x dev` page with no configuration — so an
241
- // unconditional finding would make `x doctor` red for every developer on day one and teach the
242
- // reader to skim past the report. The key is a defect only where cursors reach real clients,
243
- // who can read it out of the published package and forge a page position.
244
- // Sits here because it costs two env reads and a comparison — cheaper than binding a port.
245
- if (probe.production && probe.devCursorSecret) {
246
- findings.push(
247
- finding(
248
- 'X_CURSOR_SECRET_DEV',
249
- 'cursors are signed with the shipped development key, so a client can forge a page position',
250
- 'export ULTIMATE_CURSOR_SECRET="$(openssl rand -hex 32)"',
251
- ),
252
- );
253
- }
254
- // The storage twin of the cursor key above, and the more expensive one to get wrong: the
255
- // published string mints a signed `PUT` for any key with any `maxBytes` and `contentType`, and
256
- // `acceptSignedUpload` trusts the signed constraints over the app's own `uploadPolicy`.
257
- // Production only, for the reason the cursor check gives — every dev environment signs with the
258
- // shipped key on purpose. `@ultimat3/storage` refuses this at construction; `x doctor` is what
259
- // reports it before a deploy reaches the refusal.
260
- if (probe.production && probe.devStorageSecret) {
261
- findings.push(
262
- finding(
263
- 'X_STORAGE_SECRET_DEV',
264
- `${STORAGE_SIGNING_SECRET_KEY} is unset or holds the shipped development key, so a local-disk deploy would accept forged upload grants that override its own uploadPolicy`,
265
- 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
266
- ),
267
- );
268
- }
225
+ // Silent in development and on a shell naming no environment; reported where this machine names
226
+ // a deploy. One list, one wording (`framework-env.ts`, #679).
227
+ findings.push(...probe.frameworkFindings);
269
228
  findings.push(...(await portFindings(probe)));
270
229
  findings.push(...appUrlFindings(probe.appUrl, probe.port));
271
230
  // `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
@@ -376,7 +335,7 @@ const installedAbove = (dir: string, specifier: string): boolean => {
376
335
  */
377
336
  async function probeDatabase(url: string | undefined): Promise<Finding | null> {
378
337
  if (externalUrl(url) === undefined) return null;
379
- const client = createPostgresClient({ url, applicationName: 'x-doctor' });
338
+ const client = postgresClient({ url, applicationName: 'x-doctor' });
380
339
  try {
381
340
  const report = await checkDb(client);
382
341
  if (report.ok) return null;
@@ -407,16 +366,7 @@ export function probeFor(
407
366
  root,
408
367
  port,
409
368
  appUrl: env['APP_URL'],
410
- devCursorSecret: usesDevCursorSecret(),
411
- devStorageSecret: usesDevStorageSecret(),
412
- // `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
413
- // as its documented fallback. This read `X_ENV ?? NODE_ENV`, a spelling nothing else in the
414
- // repo reads, so a deploy declaring production the framework's own way was told it was not
415
- // production and skipped both secret findings; and the `??` short-circuited, so any non-empty
416
- // `X_ENV` shadowed a real `NODE_ENV=production` too. The non-throwing variant because
417
- // `ULTIMATE_ENV` is not in the env schema — nothing validates it at boot, and a diagnostic
418
- // that crashes on a typo is the one thing worse than a diagnostic that misses.
419
- production: tryResolveEnvironment() === 'production',
369
+ frameworkFindings: machineSecretFindings(env),
420
370
  exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
421
371
  portFree,
422
372
  database: () => probeDatabase(process.env['DATABASE_URL']),
package/src/cmd-env.ts CHANGED
@@ -15,6 +15,7 @@ import { envExampleFor, loadEnvSchema } from './app-env';
15
15
  import { requireAppRoot } from './app-root';
16
16
  import { envSpec } from './cmd-env-spec';
17
17
  import type { CliCommand, CommandContext } from './command';
18
+ import { frameworkSecretFindings } from './framework-env';
18
19
  import { msg } from './messages';
19
20
  import type { CommandResult, Finding, JsonValue } from './output';
20
21
 
@@ -67,33 +68,47 @@ async function writeExample(ctx: CommandContext): Promise<CommandResult> {
67
68
  }
68
69
 
69
70
  /**
70
- * The values are read from the real process environment, and only ever printed through
71
- * `maskedEnvValues` — `checkEnv().values` holds the actual secrets because `defineEnv()` has to
71
+ * The values are read from the environment the command was handed (`ctx.env`, the process's own
72
+ * in `bin.ts`) — one table for the app's keys and the framework's — and only ever printed through
73
+ * `maskedEnvValues`: `checkEnv().values` holds the actual secrets because `defineEnv()` has to
72
74
  * return them, and a `--json` report is the last place a DSN should appear in full.
75
+ *
76
+ * The framework's deploy-required secrets (`framework-env.ts`) are asked exactly where the boot
77
+ * refuses them — outside development/test, and where no environment is named at all — so `ULTIMATE_ENV=production x env check` answers what
78
+ * the first deployed boot would, instead of each role crash-looping on one key at a time (#679).
73
79
  */
74
80
  async function checkProcessEnv(ctx: CommandContext): Promise<CommandResult> {
75
81
  const { schema } = await requireSchema(ctx.cwd, 'check');
76
- const report = checkEnv(schema);
82
+ const report = checkEnv(schema, { env: ctx.env });
77
83
  const total = Object.keys(schema).length;
78
- const findings: readonly Finding[] = report.issues.map((issue) => ({
79
- code: 'X_ENV_MISSING',
80
- cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
81
- fix: issue.fix,
82
- docs: ERROR_DOCS_URL,
83
- at: ENV_EXAMPLE_PATH,
84
- }));
84
+ // A key the app's own declaration already reported is one finding, not two.
85
+ const reported = new Set(report.issues.map((issue) => issue.key));
86
+ // Gated by `deployed()` inside — the boot's own rule, so a table naming no environment is
87
+ // checked as the production process it boots as.
88
+ const framework = frameworkSecretFindings(ctx.env, reported);
89
+ const findings: readonly Finding[] = [
90
+ ...report.issues.map((issue) => ({
91
+ code: 'X_ENV_MISSING',
92
+ cause: `${issue.key} is ${issue.reason} (expected ${issue.expected})`,
93
+ fix: issue.fix,
94
+ docs: ERROR_DOCS_URL,
95
+ at: ENV_EXAMPLE_PATH,
96
+ })),
97
+ ...framework,
98
+ ];
99
+ const ok = findings.length === 0;
85
100
  return {
86
- ok: report.ok,
101
+ ok,
87
102
  command: 'env',
88
- summary: report.ok
103
+ summary: ok
89
104
  ? msg('cli.env.checked', { count: total })
90
- : msg('cli.env.invalid', { count: report.issues.length, total }),
105
+ : msg('cli.env.invalid', { count: findings.length, total }),
91
106
  findings,
92
107
  data: {
93
108
  variables: total,
94
109
  values: maskedEnvValues(schema, report.values) as JsonValue,
95
110
  },
96
- exitCode: report.ok ? 0 : 1,
111
+ exitCode: ok ? 0 : 1,
97
112
  };
98
113
  }
99
114
 
@@ -12,13 +12,15 @@ import { requireAppRoot } from './app-root';
12
12
  import { generateSpec } from './cmd-generate-spec';
13
13
  import type { CliCommand, CommandContext } from './command';
14
14
  import { invocationOf } from './command';
15
+ import { localiseCatalogs } from './generate-catalog-locales';
15
16
  import { assertFeatureExists } from './generate-feature';
16
17
  import { generate, sliceDir } from './generate-files';
17
18
  import { ungrantedByGenerator } from './generate-grant-findings';
18
19
  import { grantGeneratedPermissions } from './generate-grants';
19
20
  import type { Generator } from './generate-kinds';
20
21
  import { readFeature, readKind, readName, readPermission, readSurface } from './generate-kinds';
21
- import { refuseShadowedTypes } from './generate-shadow';
22
+ import { refusePluralTable } from './generate-plural';
23
+ import { refuseShadowedTypes, refuseShadowedValues } from './generate-shadow';
22
24
  import { containedPath, planWrites, writeFiles } from './generate-write';
23
25
  import { declareGeneratedImports } from './generated-imports';
24
26
  import { registerGeneratedEntities, resolveDbModule } from './handle-registration';
@@ -110,10 +112,18 @@ export const generateCommand: CliCommand = {
110
112
  ...(dbModule === undefined ? {} : { dbModule }),
111
113
  shell,
112
114
  });
113
- const files = planFor(name);
115
+ // A non-default locale's strings arrive marked, and a locale with no catalog yet is refused
116
+ // with `x i18n add <locale>` — before a dry run answers, so it answers the same files.
117
+ const files = await localiseCatalogs(root, planFor(name));
114
118
  // On the planned files, before a dry run answers or anything lands: a name whose type spelling
115
119
  // the emitted code also uses as a global is a slice that does not compile.
116
120
  refuseShadowedTypes(files, kind, name);
121
+ // And one whose binding a planned file both imports and declares (`x g job job`).
122
+ refuseShadowedValues(files, kind, name);
123
+ // And a plural slice name that an `entity.ts` this run writes would pluralise again.
124
+ refusePluralTable(files, kind, name, featureFlag, (path) =>
125
+ existsSync(containedPath(root, path)),
126
+ );
117
127
  // The caller's own invocation, EVERY flag it set included: without `--feature` the fix wrote
118
128
  // a second slice beside the one that conflicted.
119
129
  const flags = reproducedFlags(generateCommand.spec, ctx.args);