@ultimat3/cli 22.15.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 (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
package/src/cmd-db.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // directory tree. `node:path` for `join` — Bun exposes no path joiner.
12
12
  import { rm } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
- import { resolveEnvironment } from '@ultimat3/core';
14
+ import { NotImplementedError, resolveEnvironment } from '@ultimat3/core';
15
15
  import { type DriftReport, driftError, withTransaction } from '@ultimat3/db';
16
16
  import { postgresDriver } from '@ultimat3/entity';
17
17
  import { requireAppRoot } from './app-root';
@@ -22,6 +22,13 @@ import { plannedSubcommand } from './cmd-planned';
22
22
  import type { CliCommand, CommandContext } from './command';
23
23
  import { stepFinding } from './db-finding';
24
24
  import { generateAppMigration, unrenderedJson, unrenderedLines } from './db-generate';
25
+ import { liveObjectDrift } from './db-object-drift';
26
+ import {
27
+ afterWrittenMigration,
28
+ refreshSchemaDump,
29
+ type SchemaDumpRefresh,
30
+ schemaDumpJson,
31
+ } from './db-schema-refresh';
25
32
  import type { SeedPassRow } from './db-seed';
26
33
  import {
27
34
  discoverSeeds,
@@ -33,7 +40,7 @@ import {
33
40
  selectSeeds,
34
41
  } from './db-seed';
35
42
  import { DevAlreadyRunningError, liveDevLock } from './dev-lock';
36
- import { CliNotImplementedError, MissingSubcommandError, UnknownCommandError } from './errors';
43
+ import { MissingSubcommandError, UnknownCommandError } from './errors';
37
44
  import { withJobDriver } from './jobs-driver';
38
45
  import { msg } from './messages';
39
46
  import type { CommandResult, Finding } from './output';
@@ -109,34 +116,52 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
109
116
  // `x i18n add fr` failure, repeated. The count is the verdict; the gate's own `drift` step is
110
117
  // where a red belongs, and it reads the same list to decide that `x db gen` is not the fix.
111
118
  const lost = unrenderedLines(generated.unrendered);
119
+ // After the migration, from the migrations alone: the dump is what replaying them produces, so
120
+ // it is rewritten on all three outcomes — `unchanged` included, which is what makes this
121
+ // command the `fix:` on `X_SCHEMA_DUMP_DRIFT` when a framework table or a hand edit moved it.
122
+ const dump = await refreshSchemaDump(root, ctx.env);
123
+ const dumpFindings = dump.finding === undefined ? [] : [dump.finding];
112
124
  const migration = generated.migration;
113
125
  if (migration === undefined) {
114
126
  return {
115
- ok: generated.findings.length === 0,
127
+ ok: generated.findings.length === 0 && dumpFindings.length === 0,
116
128
  command: 'db',
117
129
  // The sidecar path, never a bare id: `hash-recorded` writes exactly one, and it is the file
118
130
  // the `drift` step reads back.
131
+ // `blocked` FIRST: the app did not load, so nothing was diffed, and every sentence below
132
+ // it is a claim about a comparison that never ran. It read "entities and migrations agree"
133
+ // over an exit 1.
119
134
  summary:
120
- generated.outcome === 'hash-recorded'
121
- ? msg('cli.db.gen.recorded', { file: generated.files[0] ?? '' })
122
- : msg('cli.db.gen.unchanged'),
123
- findings: generated.findings,
135
+ generated.outcome === 'blocked'
136
+ ? msg('cli.db.gen.blocked', { count: generated.findings.length })
137
+ : generated.outcome === 'hash-recorded'
138
+ ? msg('cli.db.gen.recorded', { file: generated.files[0] ?? '' })
139
+ : dump.status === 'written'
140
+ ? msg('cli.db.gen.dumped', { count: dump.written.length + dump.removed.length })
141
+ : msg('cli.db.gen.unchanged'),
142
+ findings: [...generated.findings, ...dumpFindings],
124
143
  // `files` is what this command WROTE, so the empty array here was a false claim.
125
- lines: [...generated.files.map((file) => ` ${file}`), ...lost],
144
+ lines: [...generated.files.map((file) => ` ${file}`), ...dumpLines(dump), ...lost],
126
145
  data: {
127
146
  outcome: generated.outcome,
128
147
  migration: null,
129
148
  files: [...generated.files],
130
149
  schemaHash: generated.schemaHash ?? null,
131
150
  unrendered: unrenderedJson(generated.unrendered),
151
+ schemaDump: schemaDumpJson(dump),
132
152
  },
133
153
  };
134
154
  }
135
155
  return {
136
- ok: true,
156
+ ok: dumpFindings.length === 0,
137
157
  command: 'db',
138
- summary: msg('cli.db.gen.written', { id: migration.id }),
139
- lines: [...generated.files.map((file) => ` ${file}`), ...lost],
158
+ summary:
159
+ dumpFindings.length === 0
160
+ ? msg('cli.db.gen.written', { id: migration.id })
161
+ : msg('cli.db.gen.writtenDumpFailed', { id: migration.id }),
162
+ // Exit 1 with a migration on disk: the finding has to say so, or the next move is a duplicate.
163
+ findings: dumpFindings.map((finding) => afterWrittenMigration(finding, migration.id)),
164
+ lines: [...generated.files.map((file) => ` ${file}`), ...dumpLines(dump), ...lost],
140
165
  data: {
141
166
  outcome: generated.outcome,
142
167
  migration: migration.id,
@@ -144,10 +169,20 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
144
169
  files: [...generated.files],
145
170
  schemaHash: generated.schemaHash ?? null,
146
171
  unrendered: unrenderedJson(generated.unrendered),
172
+ schemaDump: schemaDumpJson(dump),
147
173
  },
148
174
  };
149
175
  }
150
176
 
177
+ /**
178
+ * What the dump refresh did, one path per line — the same list `data.schemaDump` carries, so the
179
+ * human render and `--json` report one fact. A refresh that changed nothing prints nothing.
180
+ */
181
+ const dumpLines = (dump: SchemaDumpRefresh): readonly string[] => [
182
+ ...dump.written.map((file) => ` ${file}`),
183
+ ...dump.removed.map((file) => ` - ${file}`),
184
+ ];
185
+
151
186
  /**
152
187
  * The post-migrate report, rendered. Through `driftError` rather than a second literal: the
153
188
  * three-line `X_DB_DRIFT` output is pinned by the framework contract, and this command must not be
@@ -175,16 +210,30 @@ async function runMigrate(
175
210
  try {
176
211
  const migrated = await runMigrations({ root, env: ctx.env });
177
212
  const report = migrated.report;
213
+ // A hand-written or pulled migration reaches the dump here, with no `x db gen` in between.
214
+ const dump = await refreshSchemaDump(root, ctx.env);
215
+ // The half a snapshot cannot hold: a trigger, function, view, type or sequence this database
216
+ // carries and no migration creates. Asked only when the replay produced a catalog to ask
217
+ // against — without one there is nothing that says what the migrations create.
218
+ const objects =
219
+ dump.catalog === undefined ? [] : await liveObjectDrift(root, ctx.env, dump.catalog);
220
+ const findings = [
221
+ ...driftFindings(migrated.drift),
222
+ ...objects.map((difference) => findingFrom(driftError(difference))),
223
+ ...(dump.finding === undefined ? [] : [dump.finding]),
224
+ ];
178
225
  return {
179
- ok: migrated.drift.ok,
226
+ ok: findings.length === 0,
180
227
  command: 'db',
181
228
  summary,
182
- findings: driftFindings(migrated.drift),
229
+ findings,
230
+ lines: dumpLines(dump),
183
231
  data: {
184
232
  applied: report.applied.map((entry) => entry.id),
185
233
  skipped: report.skipped.length,
186
234
  appVersion: report.appVersion,
187
235
  durationMs: report.durationMs,
236
+ schemaDump: schemaDumpJson(dump),
188
237
  },
189
238
  };
190
239
  } catch (error) {
@@ -205,8 +254,8 @@ async function runMigrate(
205
254
  async function runReset(ctx: CommandContext, root: string): Promise<CommandResult> {
206
255
  const services = resolveServices(root, ctx.env);
207
256
  if (services.db.mode === 'external') {
208
- throw new CliNotImplementedError({
209
- feature: 'x db reset against an external Postgres',
257
+ throw new NotImplementedError({
258
+ cause: 'x db reset against an external Postgres is not implemented in this build',
210
259
  fix: 'drop and recreate the database yourself, then run: x db migrate',
211
260
  });
212
261
  }
@@ -252,7 +301,8 @@ async function runSeed(ctx: CommandContext, root: string): Promise<CommandResult
252
301
  });
253
302
  if (chosen.length === 0) {
254
303
  return {
255
- ok: true,
304
+ // A seed module that would not import may hold the seed that was asked for.
305
+ ok: discovery.findings.length === 0,
256
306
  command: 'db',
257
307
  summary: msg('cli.db.seed.none'),
258
308
  findings: discovery.findings,
@@ -8,8 +8,7 @@
8
8
  // why: Bun exposes no path-join primitive; the chart path is handed to helm as one joined string.
9
9
  import { join } from 'node:path';
10
10
  import { UltimateError } from '@ultimat3/core';
11
- import { APP_CONFIG_EXPORT } from './app-auth';
12
- import { APP_CONFIG_FILE } from './app-root';
11
+ import { loadAppConfig } from './app-config-load';
13
12
  import { BadFlagError } from './errors';
14
13
 
15
14
  /** Long enough for a migration a pre-upgrade hook must finish; helm's own 5m default is not. */
@@ -67,16 +66,18 @@ export function readLabel(flag: 'namespace' | 'release', raw: string): string {
67
66
  */
68
67
  export async function readReleaseName(root: string, flag: string | undefined): Promise<string> {
69
68
  if (flag !== undefined) return readLabel('release', flag);
70
- const module = (await import(join(root, APP_CONFIG_FILE))) as Record<string, unknown>;
71
- const config = module[APP_CONFIG_EXPORT];
72
- const name = isRecord(config) ? config['name'] : undefined;
69
+ const config = await loadAppConfig(root);
70
+ // `unknown`, not the declared `string`: core reads an absent name as `"undefined"` and lets it by.
71
+ const name: unknown = config?.name;
73
72
  if (typeof name === 'string' && name.length <= RELEASE_MAX && LABEL.test(name)) return name;
74
73
  throw new UltimateError({
75
74
  code: 'X_CONFIG_INVALID',
76
75
  cause:
77
76
  typeof name === 'string'
78
77
  ? `app.config.ts names the app "${name.slice(0, 80)}", which is not a helm release name (a DNS-1123 label of at most ${RELEASE_MAX} characters)`
79
- : 'app.config.ts exports no config.name, and x deploy --method helm names the release after it',
78
+ : config === undefined
79
+ ? `${root} has no app.config.ts, and x deploy --method helm names the release after its config.name`
80
+ : 'app.config.ts exports no config.name, and x deploy --method helm names the release after it',
80
81
  fix: 'name the release explicitly: x deploy --method helm --release my-app --json',
81
82
  });
82
83
  }
package/src/cmd-deploy.ts CHANGED
@@ -176,10 +176,10 @@ export function planDeploy(
176
176
  command: [
177
177
  'docker',
178
178
  'compose',
179
- // Compose interpolates `${SYNC_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from the shell and
180
- // `--env-file` only — never from a service's `env_file:`. Without this an operator who put
181
- // them in `.env.production`, the one file the compose file tells them to fill, had every
182
- // step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
179
+ // Compose interpolates `${SYNC_URL:?…}`, `${APP_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from
180
+ // the shell and `--env-file` only — never from a service's `env_file:`. Without this an
181
+ // operator who put them in `.env.production`, the one file the compose file tells them to
182
+ // fill, had every step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
183
183
  '--env-file',
184
184
  join(root, PROD_ENV_FILE),
185
185
  '-f',
package/src/cmd-dev.ts CHANGED
@@ -13,7 +13,6 @@ import type { OverlayNotice, RequestContext } from '@ultimat3/http';
13
13
  import { asCtx } from '@ultimat3/http';
14
14
  import type { Manifest } from '@ultimat3/manifest';
15
15
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
16
- import { createIsrController } from '@ultimat3/render/server';
17
16
  import { loadSignInPath } from './app-auth';
18
17
  import { appManifest } from './app-manifest';
19
18
  import type { StalePin } from './app-reload-graph';
@@ -50,6 +49,7 @@ import {
50
49
  resolveServices,
51
50
  withRealtimeEvents,
52
51
  } from './runtime-bindings';
52
+ import { attachedIsr } from './runtime-isr';
53
53
  import { liveFeedLabel } from './runtime-live-feed';
54
54
  import type { RuntimeOverrides } from './runtime-overrides';
55
55
  import { replicaOverrides } from './runtime-replica';
@@ -239,9 +239,10 @@ async function bootDev(
239
239
  };
240
240
  const panels = devPanels(dashboard).map((panel) => panel.key);
241
241
 
242
- // `x dev`'s own, so a reload can empty it: an `isr` page's first render was otherwise served for
243
- // its whole ttl after every save, whatever the modules behind it now said.
244
- const isr = createIsrController({ buildId });
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);
245
246
  const { routes, theme, speculation, errorStyles, mcpPath, mcpPaths } = await devRouteTable({
246
247
  isr,
247
248
  root: options.root,
@@ -350,16 +351,16 @@ async function bootDev(
350
351
  async stop() {
351
352
  watcher.close();
352
353
  await running.stop();
354
+ releaseIsr();
353
355
  await runtime.stop();
354
356
  // Released after the roles: a span opened by an in-flight request still has an exporter to
355
357
  // end into. `configureTelemetry` merges, so handing back the noop is how it is uninstalled —
356
358
  // leaving it in place would keep every span of the next `startDev` in this process's buffer.
357
359
  configureTelemetry({ exporter: noopExporter });
358
360
  traces.reset();
359
- // Released with the exporter, after the roles, for the same reason: a statement still in
360
- // flight is observed by the ledger that counted the rest of its request. Leaving it
361
- // installed would keep every statement of the next `startDev` in this process's counts —
362
- // and, worse, keep instrumentation on in a process that is no longer a dev server.
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.
363
364
  setStatementObserver(undefined);
364
365
  statements.reset();
365
366
  },
@@ -3,19 +3,12 @@
3
3
 
4
4
  import type { CommandSpec } from './parse';
5
5
 
6
- /** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
7
- export const DEFAULT_DOCTOR_PORT = 3000;
8
-
9
6
  export const doctorSpec: CommandSpec = {
10
7
  name: 'doctor',
11
8
  summary: 'environment, versions, drift, ports, PWA prerequisites — each with a fix command',
12
9
  usage: 'x doctor [--port 3000] [--json]',
13
10
  flags: [
14
- {
15
- name: 'port',
16
- type: 'string',
17
- summary: 'port to test',
18
- default: String(DEFAULT_DOCTOR_PORT),
19
- },
11
+ // No `default`: it would be indistinguishable from a typed value, and `PORT` could never win.
12
+ { name: 'port', type: 'string', summary: 'port to test (default: PORT, then 3000)' },
20
13
  ],
21
14
  };
package/src/cmd-doctor.ts CHANGED
@@ -19,12 +19,18 @@ import {
19
19
  } from '@ultimat3/db';
20
20
  import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
21
21
  import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
22
- import { DEFAULT_DOCTOR_PORT, doctorSpec } from './cmd-doctor-spec';
22
+ import { doctorSpec } from './cmd-doctor-spec';
23
23
  import type { CliCommand, CommandContext } from './command';
24
24
  import { checkMigrationSnapshots } from './db-snapshot';
25
+ import { appUrlFindings } from './doctor-app-url';
26
+ import { type AuthStorageFact, authStorageFindings, authStorageProbe } from './doctor-auth';
27
+ import { coverageExcludesProbe, withCoverageExcludes } from './doctor-coverage';
28
+ import { shippedGuardsProbe, withGuardListing } from './doctor-guards';
25
29
  import type { OfflineFallbackFact } from './doctor-offline';
26
30
  import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
27
- import { intFlagOr, PORT_RANGE, portPairAfter } from './flag-number';
31
+ import type { SealedKeysFact } from './doctor-sealed';
32
+ import { sealedKeyFindings, sealedKeysProbe } from './doctor-sealed';
33
+ import { PORT_RANGE, portPairAfter, readIntFlag } from './flag-number';
28
34
  import { ICON_SOURCE } from './icon-assets';
29
35
  import { msg } from './messages';
30
36
  import type { CommandResult, Finding } from './output';
@@ -33,6 +39,7 @@ import type { ParsedArgs } from './parse';
33
39
  import { portFree } from './port-probe';
34
40
  import { syncPortFor } from './role-sync';
35
41
  import { checkMigrationDrift } from './schema-drift';
42
+ import { portFromEnv } from './serve-env';
36
43
 
37
44
  /**
38
45
  * The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
@@ -45,6 +52,8 @@ export interface DoctorProbe {
45
52
  /** App root, or undefined when the command runs outside an app. */
46
53
  readonly root: string | undefined;
47
54
  readonly port: number;
55
+ /** `APP_URL` as this environment declares it; `undefined` when unset. */
56
+ readonly appUrl: string | undefined;
48
57
  /** True while cursors are signed with the key shipped in the published package. */
49
58
  readonly devCursorSecret: boolean;
50
59
  /**
@@ -91,6 +100,17 @@ export interface DoctorProbe {
91
100
  * reaching the disk — the same split `drift()` makes one question over.
92
101
  */
93
102
  offlineFallback(): Promise<OfflineFallbackFact>;
103
+ /**
104
+ * The app's `.sealed()` columns and the master keys this environment declares. A FACT, for the
105
+ * reason `offlineFallback` is one: loading the app and opening the secrets file is IO, and what
106
+ * a retired key beside a sealed column means is `sealedKeyFindings`' pure rule.
107
+ */
108
+ sealedKeys(): Promise<SealedKeysFact>;
109
+ /**
110
+ * What an upgrade left in the auth tables for an operator to finish: unsealed second-factor
111
+ * secrets, and tables no release reads. Asked of an external database only.
112
+ */
113
+ authStorage(): Promise<AuthStorageFact>;
94
114
  }
95
115
 
96
116
  /** What `x doctor` reads about the embedded database, without opening it. */
@@ -247,6 +267,7 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
247
267
  );
248
268
  }
249
269
  findings.push(...(await portFindings(probe)));
270
+ findings.push(...appUrlFindings(probe.appUrl, probe.port));
250
271
  // `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
251
272
  // `X_PWA_NO_FALLBACK` used to be declared here for the same two conditions the package already
252
273
  // names — two codes for one condition, one of them registered by nobody, so `x errors explain`
@@ -281,20 +302,30 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
281
302
  // author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
282
303
  // condition this diagnostic could not see at all, so the fix line ran clean over a broken app.
283
304
  findings.push(...(await probe.snapshots()));
305
+ // Sealed columns against the key ring: no key at all, or a rotation whose re-seal is pending.
306
+ findings.push(...sealedKeyFindings(await probe.sealedKeys()));
307
+ findings.push(...authStorageFindings(await probe.authStorage()));
284
308
  return findings;
285
309
  }
286
310
 
287
311
  /**
288
- * The port to TEST, and the one place `x doctor` reads it. `PORT_RANGE.min` is 0 because `x dev
289
- * --port 0` means "let the kernel pick"; here 0 means nothing, and `Bun.serve({ port: 0 })` always
290
- * succeeds — so the port check could not fail, which is worse than not running it.
312
+ * The port to TEST, and the one place `x doctor` reads it: `devPortFor`'s order — `--port`, then
313
+ * `PORT` (`portFromEnv`, the one reader of it), then 3000 — so the probe answers about the port
314
+ * `x dev` will bind. It probed 3000 under `PORT=4000` while dev bound 4000 and 4001. `PORT_RANGE.min`
315
+ * is 0 because `x dev --port 0` means "let the kernel pick"; here 0 means nothing, and
316
+ * `Bun.serve({ port: 0 })` always succeeds — so the port check could not fail.
291
317
  */
292
- export const doctorPort = (args: ParsedArgs): number =>
293
- intFlagOr(
294
- args,
295
- { name: 'port', command: 'doctor', ...PORT_RANGE, min: 1, example: 'x doctor --port 3000' },
296
- DEFAULT_DOCTOR_PORT,
297
- );
318
+ export const doctorPort = (
319
+ args: ParsedArgs,
320
+ env: Readonly<Record<string, string | undefined>>,
321
+ ): number =>
322
+ readIntFlag(args, {
323
+ name: 'port',
324
+ command: 'doctor',
325
+ ...PORT_RANGE,
326
+ min: 1,
327
+ example: 'x doctor --port 3000',
328
+ }) ?? portFromEnv(env);
298
329
 
299
330
  /**
300
331
  * `DATABASE_URL` as BOTH halves of the database question read it: unset and blank are one case.
@@ -363,12 +394,19 @@ async function probeDatabase(url: string | undefined): Promise<Finding | null> {
363
394
  }
364
395
  }
365
396
 
366
- export function probeFor(cwd: string, bunVersion: string, port: number): DoctorProbe {
397
+ export function probeFor(
398
+ cwd: string,
399
+ bunVersion: string,
400
+ port: number,
401
+ // The environment `doctorPort` read `PORT` from, so `APP_URL` is judged against the same one.
402
+ env: Readonly<Record<string, string | undefined>> = process.env,
403
+ ): DoctorProbe {
367
404
  const root = findAppRoot(cwd)?.dir;
368
405
  return {
369
406
  bunVersion,
370
407
  root,
371
408
  port,
409
+ appUrl: env['APP_URL'],
372
410
  devCursorSecret: usesDevCursorSecret(),
373
411
  devStorageSecret: usesDevStorageSecret(),
374
412
  // `ULTIMATE_ENV`, through core — the one key that says which deploy this is, with `NODE_ENV`
@@ -394,23 +432,36 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
394
432
  // `runDoctor` returns on `X_NOT_IN_APP` before this can be asked.
395
433
  offlineFallback: async () =>
396
434
  root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
435
+ sealedKeys: async () =>
436
+ root === undefined ? { columns: undefined, keys: undefined } : sealedKeysProbe(root),
437
+ authStorage: () => authStorageProbe(process.env['DATABASE_URL']),
397
438
  };
398
439
  }
399
440
 
400
441
  export const doctorCommand: CliCommand = {
401
442
  spec: doctorSpec,
402
443
  async run(ctx: CommandContext): Promise<CommandResult> {
403
- const port = doctorPort(ctx.args);
404
- const findings = await runDoctor(probeFor(ctx.cwd, ctx.bunVersion, port));
405
- return {
406
- ok: findings.length === 0,
407
- command: 'doctor',
408
- summary:
409
- findings.length === 0
410
- ? msg('cli.doctor.clean')
411
- : msg('cli.doctor.findings', { count: findings.length }),
412
- findings,
413
- data: { count: findings.length, codes: findings.map((entry) => entry.code) },
414
- };
444
+ const port = doctorPort(ctx.args, ctx.env);
445
+ const probe = probeFor(ctx.cwd, ctx.bunVersion, port, ctx.env);
446
+ const findings = await runDoctor(probe);
447
+ const listed = withGuardListing(
448
+ {
449
+ ok: findings.length === 0,
450
+ command: 'doctor',
451
+ summary:
452
+ findings.length === 0
453
+ ? msg('cli.doctor.clean')
454
+ : msg('cli.doctor.findings', { count: findings.length }),
455
+ findings,
456
+ data: { count: findings.length, codes: findings.map((entry) => entry.code) },
457
+ },
458
+ // A listing beside the verdict, never part of it (`doctor-guards.ts` says why).
459
+ probe.root === undefined ? [] : await shippedGuardsProbe(probe.root),
460
+ );
461
+ // The second listing: what the coverage floor does not see (`doctor-coverage.ts`).
462
+ return withCoverageExcludes(
463
+ listed,
464
+ probe.root === undefined ? [] : await coverageExcludesProbe(probe.root),
465
+ );
415
466
  },
416
467
  };
package/src/cmd-env.ts CHANGED
@@ -4,15 +4,39 @@
4
4
 
5
5
  // Bun ships no path-join primitive, and `.env.example` is written app-root-relative.
6
6
  import { join } from 'node:path';
7
- import { checkEnv, ENV_EXAMPLE_PATH, ERROR_DOCS_URL, maskedEnvValues } from '@ultimat3/core';
7
+ import {
8
+ checkEnv,
9
+ ENV_EXAMPLE_PATH,
10
+ ERROR_DOCS_URL,
11
+ maskedEnvValues,
12
+ UltimateError,
13
+ } from '@ultimat3/core';
8
14
  import { envExampleFor, loadEnvSchema } from './app-env';
9
15
  import { requireAppRoot } from './app-root';
10
16
  import { envSpec } from './cmd-env-spec';
11
17
  import type { CliCommand, CommandContext } from './command';
12
- import { EnvSchemaMissingError } from './errors';
13
18
  import { msg } from './messages';
14
19
  import type { CommandResult, Finding, JsonValue } from './output';
15
20
 
21
+ /**
22
+ * `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
23
+ * writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
24
+ * present", both read as a working environment declaration to whoever runs the command next.
25
+ *
26
+ * `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
27
+ * `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
28
+ * than minting a synonym.
29
+ */
30
+ export class EnvSchemaMissingError extends UltimateError {
31
+ constructor(input: { subcommand: string }) {
32
+ super({
33
+ code: 'X_CONFIG_INVALID',
34
+ cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
35
+ fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
36
+ });
37
+ }
38
+ }
39
+
16
40
  /**
17
41
  * Every subcommand needs the declaration, and an app without one is a usage error rather than an
18
42
  * empty success: `x env example` writing a two-comment file would look like it worked.