@ultimat3/cli 29.4.0 → 30.1.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 (448) hide show
  1. package/CLAUDE.md +64 -40
  2. package/README.md +61 -11
  3. package/package.json +42 -28
  4. package/src/affected-tests.ts +97 -0
  5. package/src/affected-unowned.ts +43 -0
  6. package/src/affected.ts +25 -6
  7. package/src/app-boundaries.ts +29 -1
  8. package/src/app-evals.ts +12 -4
  9. package/src/app-load-unresolved.ts +62 -0
  10. package/src/app-load.ts +30 -37
  11. package/src/app-mcp.ts +14 -2
  12. package/src/app-module-graph.ts +232 -0
  13. package/src/app-reload-graph.ts +2 -2
  14. package/src/app-root.ts +37 -1
  15. package/src/bin.ts +1 -0
  16. package/src/boundary-findings.ts +6 -0
  17. package/src/budgets-freshness.ts +32 -0
  18. package/src/budgets.ts +20 -1
  19. package/src/build-capture-env.ts +9 -0
  20. package/src/cdp-shot-a11y.ts +2 -39
  21. package/src/ci-tested.ts +243 -0
  22. package/src/client-emit-customer-runtime.ts +139 -0
  23. package/src/client-emit-customer.ts +108 -0
  24. package/src/client-emit-drift.ts +72 -0
  25. package/src/client-emit-header.ts +67 -0
  26. package/src/client-emit-schemas.ts +175 -0
  27. package/src/client-emit-types.ts +121 -0
  28. package/src/client-emit-vendor.ts +146 -0
  29. package/src/client-errors-routes.ts +229 -0
  30. package/src/client-errors-script.ts +74 -0
  31. package/src/client-vitals-route.ts +197 -0
  32. package/src/client-vitals-script.ts +56 -0
  33. package/src/cmd-affected.ts +23 -0
  34. package/src/cmd-build-spec.ts +7 -1
  35. package/src/cmd-build.ts +35 -9
  36. package/src/cmd-ci-spec.ts +42 -6
  37. package/src/cmd-ci.ts +99 -2
  38. package/src/cmd-client-customer.ts +134 -0
  39. package/src/cmd-client-source.ts +81 -0
  40. package/src/cmd-client-spec.ts +48 -0
  41. package/src/cmd-client-write.ts +41 -0
  42. package/src/cmd-client.ts +111 -0
  43. package/src/cmd-db-recount.ts +120 -0
  44. package/src/cmd-db-spec.ts +36 -6
  45. package/src/cmd-db.ts +11 -3
  46. package/src/cmd-deploy-compose.ts +61 -3
  47. package/src/cmd-deploy-drain.ts +10 -2
  48. package/src/cmd-deploy-helm.ts +11 -0
  49. package/src/cmd-deploy-preflight.ts +118 -0
  50. package/src/cmd-deploy-spec.ts +58 -6
  51. package/src/cmd-deploy-verify.ts +380 -0
  52. package/src/cmd-deploy.ts +42 -9
  53. package/src/cmd-docs.ts +34 -4
  54. package/src/cmd-doctor.ts +66 -9
  55. package/src/cmd-drill-spec.ts +25 -0
  56. package/src/cmd-drill.ts +176 -0
  57. package/src/cmd-env-init.ts +102 -0
  58. package/src/cmd-env-spec.ts +10 -3
  59. package/src/cmd-env.ts +15 -10
  60. package/src/cmd-errors-recent.ts +60 -0
  61. package/src/cmd-errors-spec.ts +9 -1
  62. package/src/cmd-errors.ts +4 -0
  63. package/src/cmd-exercise-spec.ts +19 -0
  64. package/src/cmd-exercise.ts +129 -0
  65. package/src/cmd-generate-spec.ts +9 -1
  66. package/src/cmd-generate.ts +32 -27
  67. package/src/cmd-graph-load.ts +97 -0
  68. package/src/cmd-graph-spec.ts +16 -0
  69. package/src/cmd-graph.ts +73 -0
  70. package/src/cmd-i18n.ts +4 -1
  71. package/src/cmd-impact-spec.ts +13 -0
  72. package/src/cmd-impact.ts +64 -0
  73. package/src/cmd-jobs-resolve.ts +76 -0
  74. package/src/cmd-jobs-spec.ts +21 -2
  75. package/src/cmd-jobs.ts +10 -6
  76. package/src/cmd-mail-spec.ts +28 -0
  77. package/src/cmd-mail.ts +70 -0
  78. package/src/cmd-mcp-lock.ts +69 -0
  79. package/src/cmd-mcp-spec.ts +23 -4
  80. package/src/cmd-mcp.ts +17 -3
  81. package/src/cmd-new-spec.ts +8 -1
  82. package/src/cmd-new.ts +61 -9
  83. package/src/cmd-planned.ts +0 -6
  84. package/src/cmd-repro-spec.ts +31 -0
  85. package/src/cmd-repro.ts +174 -0
  86. package/src/cmd-stop-spec.ts +21 -0
  87. package/src/cmd-stop.ts +166 -0
  88. package/src/cmd-test-spec.ts +17 -4
  89. package/src/cmd-test.ts +70 -14
  90. package/src/cmd-upgrade-spec.ts +26 -0
  91. package/src/cmd-upgrade.ts +274 -0
  92. package/src/cmd-verify-spec.ts +3 -3
  93. package/src/db-backfill.ts +6 -0
  94. package/src/db-diagnostics.ts +106 -0
  95. package/src/db-generate.ts +9 -0
  96. package/src/db-reset-template.ts +1 -1
  97. package/src/db-rolling.ts +45 -0
  98. package/src/db-seed-generate.ts +188 -0
  99. package/src/deploy-verify-findings.ts +98 -0
  100. package/src/deploy-verify-flags.ts +109 -0
  101. package/src/deploy-verify-grade.ts +185 -0
  102. package/src/deploy-verify-queues.ts +53 -0
  103. package/src/dev-boot.ts +35 -15
  104. package/src/dev-child-watch.ts +16 -4
  105. package/src/dev-dashboard-guard.ts +8 -1
  106. package/src/dev-dashboard.ts +7 -0
  107. package/src/dev-lock-identity.ts +61 -0
  108. package/src/dev-lock-port-holder.ts +80 -0
  109. package/src/dev-lock-steal.ts +124 -0
  110. package/src/dev-lock.ts +92 -120
  111. package/src/dev-mail-templates.ts +51 -0
  112. package/src/dev-rebuild.ts +26 -2
  113. package/src/dev-route-table.ts +17 -0
  114. package/src/dev-supervisor.ts +5 -2
  115. package/src/dev-watch-tree.ts +11 -10
  116. package/src/dev-watch.ts +1 -1
  117. package/src/dispatch.ts +29 -6
  118. package/src/docs-section.ts +69 -0
  119. package/src/docs-sources.ts +100 -0
  120. package/src/doctor-auth.ts +11 -2
  121. package/src/doctor-chart-alerts.ts +55 -0
  122. package/src/doctor-pooler.ts +107 -0
  123. package/src/doctor-rolling.ts +35 -0
  124. package/src/doctor-row-security.ts +40 -0
  125. package/src/document-options.ts +13 -0
  126. package/src/drill-sync-lane.ts +25 -0
  127. package/src/drill-targets.ts +39 -0
  128. package/src/duplicate-packages.ts +1 -1
  129. package/src/env-production.ts +93 -0
  130. package/src/error-codes-shot.ts +59 -0
  131. package/src/error-codes-tooling.ts +29 -0
  132. package/src/error-codes.ts +100 -54
  133. package/src/error-contract.ts +2 -2
  134. package/src/errors.ts +22 -3
  135. package/src/exercise-database.ts +99 -0
  136. package/src/exercise-findings.ts +159 -0
  137. package/src/exercise-overrides.ts +42 -0
  138. package/src/exercise-registration.ts +58 -0
  139. package/src/exercise-static.ts +88 -0
  140. package/src/exercise-subject.ts +69 -0
  141. package/src/failure-capture-boot.ts +30 -0
  142. package/src/framework-env.ts +7 -1
  143. package/src/framework-schema.ts +72 -5
  144. package/src/generate-catalog-locales.ts +19 -0
  145. package/src/generate-chart-alerts.ts +156 -0
  146. package/src/generate-feature-required.ts +65 -0
  147. package/src/generate-feature.ts +83 -6
  148. package/src/generate-files.ts +24 -0
  149. package/src/generate-format-written.ts +32 -0
  150. package/src/generate-kinds.ts +6 -0
  151. package/src/generate-sign-in-config.ts +100 -0
  152. package/src/generate-sign-in-manifest.ts +28 -0
  153. package/src/generate-sign-in-mode.ts +101 -0
  154. package/src/generate-sign-in.ts +250 -0
  155. package/src/generate-write.ts +44 -7
  156. package/src/hand-rolled-socket.ts +72 -0
  157. package/src/i18n-app-key-shapes.ts +29 -0
  158. package/src/i18n-audit.ts +10 -3
  159. package/src/i18n-registration.ts +18 -3
  160. package/src/index.ts +2 -0
  161. package/src/island-bundle.ts +4 -3
  162. package/src/island-disposer.ts +116 -0
  163. package/src/island-realtime-state.ts +34 -6
  164. package/src/island-realtime.ts +6 -2
  165. package/src/island-routes.ts +3 -2
  166. package/src/isr-query-advice.ts +6 -11
  167. package/src/isr-query-guard.ts +4 -3
  168. package/src/job-budget.ts +95 -0
  169. package/src/jobs-json.ts +11 -0
  170. package/src/lint-findings.ts +104 -0
  171. package/src/local-cli.ts +34 -4
  172. package/src/loopback-host.ts +2 -2
  173. package/src/mail-preview-findings.ts +24 -0
  174. package/src/mcp-errors.ts +82 -2
  175. package/src/mcp-host.ts +11 -1
  176. package/src/mcp-lock-findings.ts +57 -0
  177. package/src/measure-paths.ts +12 -4
  178. package/src/measure-scope.ts +27 -13
  179. package/src/messages-client.ts +23 -0
  180. package/src/messages-db.ts +56 -0
  181. package/src/messages-gate.ts +107 -0
  182. package/src/messages-repro.ts +11 -0
  183. package/src/messages-shot.ts +51 -0
  184. package/src/messages-upgrade.ts +45 -0
  185. package/src/messages.ts +53 -178
  186. package/src/metrics-endpoint.ts +23 -5
  187. package/src/new-shapes-generate.ts +71 -0
  188. package/src/new-shapes.ts +63 -0
  189. package/src/openapi-compile-ir.ts +68 -0
  190. package/src/openapi-compile-schema.ts +284 -0
  191. package/src/openapi-compile.ts +273 -0
  192. package/src/openapi-refuse.ts +45 -0
  193. package/src/otlp-export.ts +2 -1
  194. package/src/output-findings.ts +123 -0
  195. package/src/output.ts +32 -8
  196. package/src/package-boundaries.ts +110 -0
  197. package/src/page-pins.ts +23 -0
  198. package/src/plain-routes.ts +13 -0
  199. package/src/prerender-entries.ts +59 -0
  200. package/src/prerender-heaviest.ts +27 -0
  201. package/src/prerender-preload.ts +6 -0
  202. package/src/prerender-report.ts +12 -0
  203. package/src/prerender-seo-reads.ts +38 -0
  204. package/src/prerender-served.ts +22 -7
  205. package/src/prerender-vitals.ts +42 -0
  206. package/src/prerender.ts +61 -32
  207. package/src/pwa-artifacts.ts +4 -3
  208. package/src/pwa-manifest-locales.ts +1 -1
  209. package/src/registry.ts +18 -0
  210. package/src/repro-bundle.ts +65 -0
  211. package/src/repro-errors.ts +47 -0
  212. package/src/repro-job.ts +72 -0
  213. package/src/repro-literal.ts +41 -0
  214. package/src/repro-request.ts +48 -0
  215. package/src/repro-write.ts +100 -0
  216. package/src/retain-sweep.ts +27 -0
  217. package/src/retained-assets-boot.ts +113 -0
  218. package/src/retained-assets-layout.ts +110 -0
  219. package/src/retained-assets-read.ts +142 -0
  220. package/src/retained-assets-sweep.ts +70 -0
  221. package/src/retained-assets.ts +171 -0
  222. package/src/revalidated-response.ts +8 -5
  223. package/src/role-replicator.ts +7 -1
  224. package/src/role-start-devices.ts +119 -0
  225. package/src/role-start-types.ts +6 -0
  226. package/src/role-start.ts +22 -5
  227. package/src/role-sync.ts +7 -1
  228. package/src/route-islands.ts +42 -0
  229. package/src/route-table.ts +2 -1
  230. package/src/row-security-boot.ts +58 -0
  231. package/src/row-security-wanted.ts +32 -0
  232. package/src/runtime-admin.ts +0 -1
  233. package/src/runtime-assets-site-cache.ts +55 -0
  234. package/src/runtime-assets-site.ts +136 -0
  235. package/src/runtime-assets.ts +99 -55
  236. package/src/runtime-bindings.ts +19 -3
  237. package/src/runtime-cache.ts +23 -14
  238. package/src/runtime-isr-outcome.ts +114 -60
  239. package/src/runtime-purge.ts +14 -2
  240. package/src/runtime-queue.ts +31 -4
  241. package/src/runtime-render-document.ts +204 -0
  242. package/src/runtime-render.ts +48 -198
  243. package/src/runtime-replica-position.ts +62 -0
  244. package/src/runtime-replica.ts +18 -3
  245. package/src/runtime-s3-path-style.ts +17 -0
  246. package/src/runtime-services.ts +19 -6
  247. package/src/runtime-statement-bound.ts +22 -0
  248. package/src/runtime-storage-upload.ts +9 -0
  249. package/src/runtime-storage.ts +41 -81
  250. package/src/schema-diff-views.ts +54 -0
  251. package/src/schema-diff.ts +21 -3
  252. package/src/schema-drift.ts +28 -9
  253. package/src/schema-dump-drift.ts +91 -7
  254. package/src/script-csp.ts +1 -1
  255. package/src/seo-cards.ts +26 -0
  256. package/src/seo-routes.ts +22 -8
  257. package/src/serve-boot.ts +54 -21
  258. package/src/serve-drain.ts +1 -0
  259. package/src/serve-env.ts +26 -8
  260. package/src/serve-retire.ts +12 -6
  261. package/src/serve-web.ts +42 -4
  262. package/src/serve.ts +9 -7
  263. package/src/site-asset-routes.ts +7 -1
  264. package/src/site-config.ts +4 -0
  265. package/src/site-llms-pages.ts +106 -0
  266. package/src/site-llms.ts +64 -0
  267. package/src/site-seo-write.ts +4 -2
  268. package/src/site-seo.ts +55 -6
  269. package/src/source-mtime.ts +50 -0
  270. package/src/static-capture-errors.ts +41 -0
  271. package/src/static-capture.ts +56 -0
  272. package/src/static-cards-serve.ts +39 -0
  273. package/src/static-cards.ts +93 -0
  274. package/src/static-document.ts +14 -13
  275. package/src/static-images.ts +115 -0
  276. package/src/stream-hole-scope.ts +55 -0
  277. package/src/style-bundle.ts +1 -1
  278. package/src/style-routes.ts +3 -2
  279. package/src/sw-precache-plan.ts +2 -2
  280. package/src/sw-routes.ts +2 -2
  281. package/src/templates/action-tests.ts +306 -0
  282. package/src/templates/action.ts +81 -269
  283. package/src/templates/backfill.ts +1 -1
  284. package/src/templates/entity.ts +21 -5
  285. package/src/templates/github/ci.yml.ts +8 -9
  286. package/src/templates/guard-bare-error.ts +2 -2
  287. package/src/templates/guard-error-declared.ts +263 -0
  288. package/src/templates/guard-native-confirm.ts +132 -0
  289. package/src/templates/guard-raw-fetch.ts +151 -0
  290. package/src/templates/guard-raw-interval.ts +121 -0
  291. package/src/templates/guard-repo-raw-sql.ts +71 -2
  292. package/src/templates/job.ts +1 -1
  293. package/src/templates/naming.ts +5 -0
  294. package/src/templates/pinned/docker-compose.yml +6 -0
  295. package/src/templates/pinned-images.ts +8 -0
  296. package/src/templates/policy.ts +85 -2
  297. package/src/templates/query.ts +70 -13
  298. package/src/templates/resource-create.ts +3 -1
  299. package/src/templates/resource-form-island.ts +15 -4
  300. package/src/templates/resource-page-db.ts +83 -0
  301. package/src/templates/resource-page.ts +161 -52
  302. package/src/templates/route.ts +4 -6
  303. package/src/templates/scaffold-app.ts +3 -0
  304. package/src/templates/scaffold-auth.ts +2 -1
  305. package/src/templates/scaffold-bin.ts +5 -52
  306. package/src/templates/scaffold-claude-agents.ts +19 -19
  307. package/src/templates/scaffold-claude-commands.ts +28 -31
  308. package/src/templates/scaffold-claude.ts +26 -23
  309. package/src/templates/scaffold-container-compose.ts +6 -1
  310. package/src/templates/scaffold-container.ts +24 -4
  311. package/src/templates/scaffold-dashboard-bare.ts +3 -3
  312. package/src/templates/scaffold-dashboard-example-tests.ts +49 -9
  313. package/src/templates/scaffold-dashboard-example-view.ts +156 -0
  314. package/src/templates/scaffold-dashboard-example.ts +43 -131
  315. package/src/templates/scaffold-dashboard-shared.ts +3 -7
  316. package/src/templates/scaffold-db-client.ts +20 -6
  317. package/src/templates/scaffold-db-package.ts +5 -14
  318. package/src/templates/scaffold-docs.ts +73 -57
  319. package/src/templates/scaffold-domain-package.ts +18 -14
  320. package/src/templates/scaffold-entries.ts +7 -2
  321. package/src/templates/scaffold-exercise.ts +37 -0
  322. package/src/templates/scaffold-guards.ts +13 -1
  323. package/src/templates/scaffold-helm-alerts.ts +234 -0
  324. package/src/templates/scaffold-helm-templates.ts +7 -5
  325. package/src/templates/scaffold-helm.ts +46 -12
  326. package/src/templates/scaffold-i18n.ts +6 -2
  327. package/src/templates/scaffold-mcp-endpoint.ts +182 -0
  328. package/src/templates/scaffold-mcp-json.ts +31 -0
  329. package/src/templates/scaffold-mcp-package.ts +59 -56
  330. package/src/templates/scaffold-repo.ts +7 -2
  331. package/src/templates/scaffold-shell.ts +45 -79
  332. package/src/templates/scaffold-test-services.ts +13 -5
  333. package/src/templates/shapes/api-keys.ts +170 -0
  334. package/src/templates/shapes/api.ts +101 -0
  335. package/src/templates/shapes/blog-site.ts +340 -0
  336. package/src/templates/shapes/blog-slice.ts +265 -0
  337. package/src/templates/shapes/blog.ts +114 -0
  338. package/src/templates/shapes/dashboard-detail.ts +242 -0
  339. package/src/templates/shapes/dashboard.ts +53 -0
  340. package/src/templates/shapes/index.ts +17 -0
  341. package/src/templates/shapes/overlay.ts +99 -0
  342. package/src/templates/shapes/shared-edits.ts +46 -0
  343. package/src/templates/sign-in-action-tests.ts +237 -0
  344. package/src/templates/sign-in-authenticator.ts +112 -0
  345. package/src/templates/sign-in-oidc.ts +189 -0
  346. package/src/templates/sign-in-pages.ts +148 -0
  347. package/src/templates/sign-in-password.ts +216 -0
  348. package/src/templates/sign-in-shared.ts +268 -0
  349. package/src/templates/slice-foundation.ts +70 -15
  350. package/src/test-batches.ts +1 -1
  351. package/src/test-database.ts +1 -1
  352. package/src/test-shards.ts +7 -1
  353. package/src/test-workers.ts +18 -12
  354. package/src/tool-gate-names.ts +34 -0
  355. package/src/upgrade-agents-link.ts +49 -0
  356. package/src/upgrade-bun-floor.ts +225 -0
  357. package/src/upgrade-gate.ts +228 -0
  358. package/src/upgrade-pins.ts +63 -0
  359. package/src/upgrade-plan.ts +214 -0
  360. package/src/upgrade-registry.ts +53 -0
  361. package/src/upgrade-report.ts +139 -0
  362. package/src/upgrade-root.ts +74 -0
  363. package/src/verify-budgets-build.ts +118 -0
  364. package/src/verify-checks.ts +67 -24
  365. package/src/verify-coverage-run.ts +1 -1
  366. package/src/verify-e2e-lane.ts +41 -0
  367. package/src/verify-exercise.ts +64 -0
  368. package/src/verify-floor.ts +41 -0
  369. package/src/verify-merge.ts +19 -4
  370. package/src/verify-role-load.ts +2 -0
  371. package/src/verify-run.ts +28 -8
  372. package/src/verify-shard.ts +12 -2
  373. package/src/verify-skip-ceiling.ts +87 -0
  374. package/src/verify-step.ts +5 -0
  375. package/src/verify-tests.ts +25 -5
  376. package/src/view-drift.ts +55 -0
  377. package/src/worker-bundle.ts +3 -2
  378. package/src/workspace-graph.ts +38 -19
  379. package/src/write-generated.ts +43 -0
  380. package/src/zone-boot.ts +20 -0
  381. package/wiki/Actions.md +342 -0
  382. package/wiki/Admin-Dashboard.md +432 -0
  383. package/wiki/Agents.md +958 -0
  384. package/wiki/Auth.md +195 -0
  385. package/wiki/Bare-VM.md +111 -0
  386. package/wiki/Batching-And-Preloading.md +136 -0
  387. package/wiki/Building-Your-Own-Base.md +203 -0
  388. package/wiki/CI-Parallel-Gate.md +220 -0
  389. package/wiki/CLI-Reference.md +1894 -0
  390. package/wiki/Caching-And-Invalidation.md +255 -0
  391. package/wiki/Client-Data.md +194 -0
  392. package/wiki/Client-Navigation.md +513 -0
  393. package/wiki/Compared.md +93 -0
  394. package/wiki/Configuration.md +868 -0
  395. package/wiki/Contributing.md +12 -0
  396. package/wiki/Deploy-Recipes.md +122 -0
  397. package/wiki/Deployment.md +508 -0
  398. package/wiki/Entities-And-Migrations.md +1116 -0
  399. package/wiki/FAQ.md +175 -0
  400. package/wiki/Feature-Flags.md +65 -0
  401. package/wiki/Getting-Started.md +221 -0
  402. package/wiki/Home.md +160 -0
  403. package/wiki/I18n.md +239 -0
  404. package/wiki/Installation.md +206 -0
  405. package/wiki/Interface-Rules.md +339 -0
  406. package/wiki/Jobs-And-Workflows.md +783 -0
  407. package/wiki/Known-Gaps.md +185 -0
  408. package/wiki/MCP-And-AI.md +753 -0
  409. package/wiki/Mail.md +138 -0
  410. package/wiki/Migrating-An-Existing-App.md +594 -0
  411. package/wiki/Migrating-From-Django.md +54 -0
  412. package/wiki/Migrating-From-Laravel.md +53 -0
  413. package/wiki/Migrating-From-Node-Drizzle.md +53 -0
  414. package/wiki/Migrating-From-Rails.md +53 -0
  415. package/wiki/Migrations-And-Backfills.md +552 -0
  416. package/wiki/Money.md +338 -0
  417. package/wiki/N-Plus-One-Detection.md +197 -0
  418. package/wiki/Notify.md +208 -0
  419. package/wiki/Observability.md +245 -0
  420. package/wiki/Outbound-HTTP.md +109 -0
  421. package/wiki/PWA-And-Offline.md +343 -0
  422. package/wiki/Policies-And-Authz.md +251 -0
  423. package/wiki/Project-Layout.md +150 -0
  424. package/wiki/Queries-And-Live-Queries.md +429 -0
  425. package/wiki/Realtime.md +734 -0
  426. package/wiki/Resource-Management.md +121 -0
  427. package/wiki/Routes-And-Render-Modes.md +521 -0
  428. package/wiki/SEO.md +288 -0
  429. package/wiki/Scheduled-Tasks.md +175 -0
  430. package/wiki/Scraping.md +493 -0
  431. package/wiki/Shapes.md +63 -0
  432. package/wiki/Stability.md +97 -0
  433. package/wiki/Static-Assets.md +166 -0
  434. package/wiki/Storage-And-Uploads.md +404 -0
  435. package/wiki/Testing.md +1314 -0
  436. package/wiki/The-Eight-Primitives.md +247 -0
  437. package/wiki/Theming.md +362 -0
  438. package/wiki/Timezones-And-Dates.md +194 -0
  439. package/wiki/Troubleshooting.md +163 -0
  440. package/wiki/Tutorial-01-First-App.md +289 -0
  441. package/wiki/Tutorial-02-First-Feature.md +339 -0
  442. package/wiki/Tutorial-03-Auth-And-Admin.md +379 -0
  443. package/wiki/Tutorial-04-Jobs-And-Realtime.md +263 -0
  444. package/wiki/Tutorial-05-Deploy-Free.md +231 -0
  445. package/wiki/Tutorial-06-Growing-Up.md +158 -0
  446. package/wiki/UI-Components.md +156 -0
  447. package/wiki/Upgrading.md +3667 -0
  448. package/src/stored-object-headers.ts +0 -43
@@ -0,0 +1,58 @@
1
+ // A generator's entries in `apps/web/exercise.ts`: what `x g sign-in` writes that the exercise pass
2
+ // cannot run as generated, each turned off with its reason, so the app's next `x verify` is green.
3
+ // An entry the app already declares is the app's and is never touched.
4
+
5
+ import { exerciseFiles } from './templates/scaffold-exercise';
6
+
7
+ export const EXERCISE_FILE = 'apps/web/exercise.ts';
8
+
9
+ /**
10
+ * The password sign-in's two actions the pass cannot drive, by the file that declares each. One line
11
+ * each in the app's file, inside Biome's 100 columns: `login()` refuses the drawn address and password.
12
+ */
13
+ const SIGN_IN_OFF: ReadonlyMap<string, readonly [string, string]> = new Map([
14
+ [
15
+ 'apps/web/app/auth/actions/create-session.ts',
16
+ ['createSession', 'needs a real password; the pass draws one no account holds'],
17
+ ],
18
+ [
19
+ 'apps/web/app/auth/actions/confirm-session.ts',
20
+ ['confirmSession', 'answers the challenge createSession holds; the pass holds none'],
21
+ ],
22
+ ]);
23
+
24
+ /** The `off` entries the written files are owed, by primitive name. */
25
+ export const signInExerciseEntries = (
26
+ written: readonly string[],
27
+ ): readonly (readonly [string, string])[] =>
28
+ written.flatMap((path) => {
29
+ const entry = SIGN_IN_OFF.get(path);
30
+ return entry === undefined ? [] : [entry];
31
+ });
32
+
33
+ const DEFINE = 'defineExercise({';
34
+
35
+ /**
36
+ * `source` with each entry not already named in it inserted at the top of `defineExercise({ … })`;
37
+ * `undefined` when there is no file, which starts from `x new`'s. A file with no `defineExercise({`
38
+ * is the app's own shape and is left as it is.
39
+ */
40
+ export function exerciseWithEntries(
41
+ source: string | undefined,
42
+ entries: readonly (readonly [string, string])[],
43
+ ): string {
44
+ const base = source ?? String(exerciseFiles()[0]?.contents ?? '');
45
+ const at = base.indexOf(DEFINE);
46
+ if (at === -1) return base;
47
+ const missing = entries.filter(([name]) => !new RegExp(`\\b${name}\\s*:`).test(base));
48
+ if (missing.length === 0) return base;
49
+ const lines = missing.map(([name, why]) => ` ${name}: { off: ${singleQuoted(why)} },`);
50
+ const after = at + DEFINE.length;
51
+ // `defineExercise({})` — the scaffold's — opens onto its own close: the close gets its own line.
52
+ const rest = base.slice(after);
53
+ return `${base.slice(0, after)}\n${lines.join('\n')}${rest.startsWith('}') ? '\n' : ''}${rest}`;
54
+ }
55
+
56
+ /** A single-quoted TypeScript string literal, Biome's own quote style. */
57
+ const singleQuoted = (text: string): string =>
58
+ `'${text.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
@@ -0,0 +1,88 @@
1
+ // The static half of the tenant proof, under the `exercise` step: an entity with no tenant column
2
+ // whose references reach one that has a tenant, and no `shared` reason, is a row of one tenant
3
+ // readable by id from another — the guard scopes only tables that carry the column. Pure:
4
+ // descriptions in, findings out (the tenant columns default to the registry's own projection).
5
+
6
+ import type { EntityDescription, TenantColumnDescription } from '@ultimat3/entity';
7
+ import { describeTenancy } from '@ultimat3/entity';
8
+ import type { Finding } from './output';
9
+ import { quoteArg } from './shell-quote';
10
+
11
+ /** `"<table>.<column>"` → the table. Physical names hold no `.`, so the first one splits it. */
12
+ const targetTable = (reference: string): string => reference.slice(0, reference.indexOf('.'));
13
+
14
+ /**
15
+ * The tenant ROOTS: each table a tenant column of `entities` references (`orgs`, for
16
+ * `orgId -> orgs.id`). It carries no tenant column because it IS the tenant — `seedGraph` holds one
17
+ * row of it per tenant (`seed-graph.ts`'s `roots`, the same rule over entity objects).
18
+ */
19
+ const tenantRoots = (
20
+ byTable: ReadonlyMap<string, EntityDescription>,
21
+ tenancy: readonly TenantColumnDescription[],
22
+ ): ReadonlySet<string> => {
23
+ const roots = new Set<string>();
24
+ for (const { table, column } of tenancy) {
25
+ const reference = byTable.get(table)?.columns.find((c) => c.column === column)?.references;
26
+ if (reference === null || reference === undefined) continue;
27
+ if (targetTable(reference) !== table) roots.add(targetTable(reference));
28
+ }
29
+ return roots;
30
+ };
31
+
32
+ /**
33
+ * The shortest reference path from `start` to a tenant-columned entity or a tenant root, or
34
+ * `null`. A `shared` entity is not walked through: its rows belong to no tenant by declaration, so
35
+ * what hangs off it hangs off a global row. Either kind of tenant ends the walk, found.
36
+ */
37
+ const pathToTenant = (
38
+ start: EntityDescription,
39
+ byTable: ReadonlyMap<string, EntityDescription>,
40
+ roots: ReadonlySet<string>,
41
+ ): readonly string[] | null => {
42
+ const seen = new Set([start.table]);
43
+ const queue: (readonly EntityDescription[])[] = [[start]];
44
+ for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
45
+ const here = path[path.length - 1];
46
+ for (const column of here?.columns ?? []) {
47
+ if (column.references === null) continue;
48
+ const next = byTable.get(targetTable(column.references));
49
+ if (next === undefined || seen.has(next.table)) continue;
50
+ seen.add(next.table);
51
+ if (next.orgScoped || roots.has(next.table)) {
52
+ return [...path, next].map((entity) => entity.name);
53
+ }
54
+ if (next.shared === undefined) queue.push([...path, next]);
55
+ }
56
+ }
57
+ return null;
58
+ };
59
+
60
+ const finding = (entity: EntityDescription, path: readonly string[], root: boolean): Finding => ({
61
+ code: 'X_ENTITY_TENANT_UNDECLARED',
62
+ cause: `${entity.name} has no tenant column and references ${path.join(' -> ')}, ${root ? 'the tenant root' : 'which has one'}, and declares no shared reason: a row of one tenant is readable by id from another. Add a tenant column to its columns, or declare why it belongs to no tenant`,
63
+ fix: `orgId: uuid().tenant() # in entity('${entity.name}', { columns }), then x db gen ${quoteArg(`${entity.table} tenant`)}; or shared: '<why it belongs to no tenant>' in that entity()`,
64
+ // The registry records no declaring file, so the entity's name is the locator.
65
+ at: entity.name,
66
+ });
67
+
68
+ /**
69
+ * `tenancy` is which column scopes each tenant table — the description does not say — and only
70
+ * rows naming a table in `entities` count, so a registered entity outside the set roots nothing.
71
+ */
72
+ export function entityTenantFindings(
73
+ entities: readonly EntityDescription[],
74
+ tenancy: readonly TenantColumnDescription[] = describeTenancy(),
75
+ ): readonly Finding[] {
76
+ const byTable = new Map(entities.map((entity) => [entity.table, entity]));
77
+ const roots = tenantRoots(byTable, tenancy);
78
+ const byName = new Map(entities.map((entity) => [entity.name, entity]));
79
+ return [...entities]
80
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
81
+ .flatMap((entity) => {
82
+ if (entity.orgScoped || entity.shared !== undefined || roots.has(entity.table)) return [];
83
+ const path = pathToTenant(entity, byTable, roots);
84
+ if (path === null) return [];
85
+ const end = byName.get(path[path.length - 1] ?? '');
86
+ return [finding(entity, path, end !== undefined && !end.orgScoped)];
87
+ });
88
+ }
@@ -0,0 +1,69 @@
1
+ // The subject proof under the `exercise` step. Static: every `defineSubject()` planned against
2
+ // every registered entity — a table keyed to the subject with no treatment is
3
+ // `X_SUBJECT_TABLE_UNDECLARED` naming it, so "you forgot last month's table" is a red gate.
4
+ // Dynamic: `subjectProof` erases one generated person and searches every table for them.
5
+
6
+ import { isUltimateError } from '@ultimat3/core';
7
+ import type { DbClient } from '@ultimat3/db';
8
+ import { identifier, sql } from '@ultimat3/db';
9
+ import type { EntityCore, RegistryEntry } from '@ultimat3/entity';
10
+ import type { Subject } from '@ultimat3/jobs';
11
+ import { planSubject } from '@ultimat3/jobs';
12
+ import { entitySeedWriter, subjectProof } from '@ultimat3/testing';
13
+ import type { Finding } from './output';
14
+
15
+ export function subjectFindings(
16
+ subjects: readonly Subject<never>[],
17
+ entries: readonly RegistryEntry[],
18
+ ): readonly Finding[] {
19
+ return subjects.flatMap((subject) =>
20
+ planSubject({
21
+ subject: subject.entity,
22
+ tables: subject.init.tables,
23
+ columns: subject.init.columns,
24
+ entries,
25
+ }).problems.flatMap((problem): Finding[] =>
26
+ isUltimateError(problem)
27
+ ? [{ code: problem.code, cause: problem.cause, fix: problem.fix, at: subject.entity }]
28
+ : [],
29
+ ),
30
+ );
31
+ }
32
+
33
+ /**
34
+ * The dynamic half: each declared subject erased on its own generated graph, on the pass's scratch
35
+ * database, and every table searched (`subjectProof`). A seed of its own, so the proof's rows never
36
+ * collide with the pass's.
37
+ */
38
+ export async function subjectProofFindings(
39
+ subjects: readonly Subject<never>[],
40
+ cores: Readonly<Record<string, EntityCore>>,
41
+ client: DbClient,
42
+ ): Promise<readonly Finding[]> {
43
+ const findings: Finding[] = [];
44
+ for (const [index, subject] of subjects.entries()) {
45
+ // A plan the static half refused is reported there, once; an erase that throws is a finding.
46
+ const problems = await subjectProof(subject, {
47
+ entities: Object.values(cores),
48
+ writer: entitySeedWriter({ entities: cores, client }),
49
+ read: (entity) =>
50
+ client.query<Record<string, unknown>>(sql`select * from ${identifier(entity.$table)}`),
51
+ seed: SUBJECT_PROOF_SEED + index,
52
+ }).then(
53
+ (result) => result.problems.filter((p) => p.code === 'X_SUBJECT_ERASE_INCOMPLETE'),
54
+ (error: unknown) => (isUltimateError(error) ? [error] : Promise.reject(error)),
55
+ );
56
+ for (const problem of problems) {
57
+ findings.push({
58
+ code: problem.code,
59
+ cause: problem.cause,
60
+ fix: problem.fix,
61
+ at: subject.entity,
62
+ });
63
+ }
64
+ }
65
+ return findings;
66
+ }
67
+
68
+ /** Far from the small seeds a developer passes to `x exercise --seed`. */
69
+ const SUBJECT_PROOF_SEED = 0x5ab1ec7;
@@ -0,0 +1,30 @@
1
+ // Single responsibility: arming failure capture for a booted role. `failureCapture.enabled` in
2
+ // `app.config.ts` installs the Postgres bundle store (`x_failures`, applied by the boot's framework
3
+ // schema); `x dev` arms it whatever the file says. Off, nothing is installed and `invoke` runs bare.
4
+
5
+ import type { AppConfig } from '@ultimat3/core';
6
+ import { FAILURE_CAPTURE_DEFAULTS, setFailureStore, settleFailureCaptures } from '@ultimat3/core';
7
+ import { postgresFailureStore } from '@ultimat3/db';
8
+
9
+ export interface ArmFailureCaptureOptions {
10
+ /** `x dev`: on whatever `app.config.ts` says — a development process's input is the developer's. */
11
+ readonly force?: boolean | undefined;
12
+ }
13
+
14
+ /** Install the store when capture is on; answer the undo, which settles in-flight saves first. */
15
+ export function armFailureCapture(
16
+ config: AppConfig | undefined,
17
+ options: ArmFailureCaptureOptions = {},
18
+ ): () => Promise<void> {
19
+ const block = config?.failureCapture ?? FAILURE_CAPTURE_DEFAULTS;
20
+ if (!block.enabled && options.force !== true) return async () => undefined;
21
+ const previous = setFailureStore(postgresFailureStore(), {
22
+ perHour: block.perHour,
23
+ maxRows: block.maxRows,
24
+ maxBytes: block.maxBytes,
25
+ });
26
+ return async () => {
27
+ await settleFailureCaptures();
28
+ setFailureStore(previous);
29
+ };
30
+ }
@@ -43,6 +43,12 @@ export const namedDeployed = (env: EnvTable): boolean => {
43
43
  return environment === 'staging' || environment === 'production';
44
44
  };
45
45
 
46
+ /** Does `env` name no environment? The boot then reads it as production, and `x env check` says so. */
47
+ export const namesNoEnvironment = (env: EnvTable): boolean =>
48
+ // Core's reading, twice: only a table that names nothing answers with whichever fallback it got.
49
+ tryResolveEnvironment({ env, fallback: 'production' }) !==
50
+ tryResolveEnvironment({ env, fallback: 'development' });
51
+
46
52
  /**
47
53
  * What the app's `app.config.ts` decides about which framework keys it owes — read by the caller,
48
54
  * which has the root (`appSecretFacts`). `x doctor` asks of no config and passes `NO_APP_FACTS`.
@@ -99,7 +105,7 @@ export const FRAMEWORK_SECRETS: readonly FrameworkSecret[] = [
99
105
  why: 'Signs upload grants on the embedded disk. Unset, every role refuses to boot (X_ENV_MISSING).',
100
106
  condition: 'Not read when S3_ENDPOINT and S3_BUCKET select object storage.',
101
107
  cause: `${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`,
102
- fix: `export ${STORAGE_SIGNING_SECRET_KEY}="$(openssl rand -hex 32)"`,
108
+ fix: `openssl rand -hex 32 # put the output in this deploy's secret store as ${STORAGE_SIGNING_SECRET_KEY}; this machine: ULTIMATE_ENV=development x env check`,
103
109
  // The boot's own storage choice (`runtime-bindings.ts`), never restated. NOT "the app declares
104
110
  // no storage": the boot builds its disk before any app module loads, and an app reaches it
105
111
  // through a bare `disk()` with no `defineStorage` of its own
@@ -6,16 +6,31 @@
6
6
  // A framework table an app has to install by hand is a table that will be missing in production on
7
7
  // the one code path that needs it, and it surfaces as a Postgres `42P01` from inside a worker.
8
8
 
9
- import { SQL_AUDIT_TABLE, SQL_IDEMPOTENCY_TABLE } from '@ultimat3/action';
10
- import { ADMIN_AUDIT_TABLE, SQL_ADMIN_AUDIT_TABLE } from '@ultimat3/admin/schema';
11
- import { AUTH_TABLE_NAMES, AUTH_TABLES, SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
9
+ import { SQL_AUDIT_TABLE, SQL_IDEMPOTENCY_TABLE, SQL_METER_TABLE } from '@ultimat3/action';
10
+ import {
11
+ ADMIN_AUDIT_TABLE,
12
+ ERROR_GROUPS_TABLE,
13
+ SQL_ADMIN_AUDIT_TABLE,
14
+ SQL_ERROR_GROUPS_TABLE,
15
+ } from '@ultimat3/admin/schema';
16
+ import {
17
+ AUTH_SERVER_TABLES,
18
+ AUTH_TABLE_NAMES,
19
+ AUTH_TABLES,
20
+ SQL_AUTH_LIMIT_TABLES,
21
+ SQL_AUTH_SERVER_TABLES,
22
+ SQL_TOTP_REPLAY_TABLE,
23
+ } from '@ultimat3/auth';
24
+ import { FAILURES_TABLE, SQL_FAILURES_TABLE } from '@ultimat3/db';
12
25
  import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
13
26
  import { SQL_JOBS_TABLE } from '@ultimat3/jobs';
14
27
  import { MCP_CONFIRMATIONS_TABLE, SQL_MCP_CONFIRMATIONS_TABLE } from '@ultimat3/mcp/schema';
15
28
  import {
29
+ PUSH_DEVICES_TABLE,
16
30
  SQL_NOTIFY_DELIVERIES_TABLE,
17
31
  SQL_NOTIFY_DIGESTS_TABLE,
18
32
  SQL_NOTIFY_INBOX_TABLE,
33
+ SQL_PUSH_DEVICES_TABLE,
19
34
  } from '@ultimat3/notify';
20
35
  import { PUSH_SUBSCRIPTIONS_TABLE, SQL_PUSH_SUBSCRIPTIONS_TABLE } from '@ultimat3/pwa/schema';
21
36
  import { FrameworkSchemaFailedError } from './schema-errors';
@@ -75,6 +90,13 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
75
90
  tables: Object.freeze(['x_audit']),
76
91
  ddl: Object.freeze([SQL_AUDIT_TABLE]),
77
92
  }),
93
+ // The counters behind `meter:`. One row per tenant × meter × period declaration, rolled over in
94
+ // place, so it ships no purge: the table is bounded by its subjects, never by time.
95
+ Object.freeze({
96
+ pkg: '@ultimat3/action',
97
+ tables: Object.freeze(['x_meter']),
98
+ ddl: Object.freeze([SQL_METER_TABLE]),
99
+ }),
78
100
  Object.freeze({
79
101
  pkg: '@ultimat3/http',
80
102
  tables: Object.freeze(['x_rate_limit']),
@@ -85,8 +107,14 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
85
107
  tables: Object.freeze(['x_auth_lockouts']),
86
108
  ddl: Object.freeze([SQL_AUTH_LIMIT_TABLES]),
87
109
  }),
110
+ // Spent TOTP steps, so a code is single-use across the fleet rather than per replica.
111
+ Object.freeze({
112
+ pkg: '@ultimat3/auth',
113
+ tables: Object.freeze(['x_totp_steps']),
114
+ ddl: Object.freeze([SQL_TOTP_REPLAY_TABLE]),
115
+ }),
88
116
  /**
89
- * The five tables `PostgresAuthAdapter` reads, and the oldest hole in this list.
117
+ * The eight tables `PostgresAuthAdapter` reads, and the oldest hole in this list.
90
118
  *
91
119
  * Until this row nothing applied them, in dev or in production: they are not `entity()`
92
120
  * declarations, so `x db gen` never saw them, and an app was left to paste per-table exports
@@ -94,7 +122,7 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
94
122
  * exports are gone; `AUTH_TABLES` is the whole DDL, the 1.3 `x_users` upgrade included, applied
95
123
  * here on exactly the rule the rate-limit and audit rows already follow.
96
124
  *
97
- * `AUTH_TABLE_NAMES` rather than five literals: @ultimat3/auth already publishes the list, and a
125
+ * `AUTH_TABLE_NAMES` rather than eight literals: @ultimat3/auth already publishes the list, and a
98
126
  * second copy is a second thing to keep right when a table is added.
99
127
  */
100
128
  Object.freeze({
@@ -102,6 +130,16 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
102
130
  tables: AUTH_TABLE_NAMES,
103
131
  ddl: AUTH_TABLES,
104
132
  }),
133
+ /**
134
+ * `authorizationServer()`'s clients, codes, grants, tokens and device codes — what
135
+ * `postgresAuthServerStore()` reads. Applied whether or not the app runs a server, for every
136
+ * row's reason: enabling it must not be the deploy that discovers the tables were never created.
137
+ */
138
+ Object.freeze({
139
+ pkg: '@ultimat3/auth',
140
+ tables: AUTH_SERVER_TABLES,
141
+ ddl: Object.freeze([SQL_AUTH_SERVER_TABLES]),
142
+ }),
105
143
  /**
106
144
  * The delivery ledger is what stops a replayed notifier job sending twice, and it is the entry
107
145
  * whose absence is least visible: without the table the ledger's first `claim` raises `42P01`
@@ -126,6 +164,14 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
126
164
  tables: Object.freeze(['x_notify_digests']),
127
165
  ddl: Object.freeze([SQL_NOTIFY_DIGESTS_TABLE]),
128
166
  }),
167
+ // `postgresPushDeviceStore`'s native registrations — what `pushRegister()` writes and
168
+ // `nativePush()` reads. Installed whether or not the app ships a native client, for every row's
169
+ // reason: shipping one must not be the deploy whose first registration meets `42P01`.
170
+ Object.freeze({
171
+ pkg: '@ultimat3/notify',
172
+ tables: Object.freeze([PUSH_DEVICES_TABLE]),
173
+ ddl: Object.freeze([SQL_PUSH_DEVICES_TABLE]),
174
+ }),
129
175
  /**
130
176
  * The admin's durable audit trail — what `postgresAuditLog()` writes and every detail page's
131
177
  * history card reads. Without this row an app that chose the durable log met `42P01` on the
@@ -137,6 +183,27 @@ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
137
183
  tables: Object.freeze([ADMIN_AUDIT_TABLE]),
138
184
  ddl: Object.freeze([SQL_ADMIN_AUDIT_TABLE]),
139
185
  }),
186
+ /**
187
+ * `errorGroupsSink`'s table — one row per error fingerprint, what `/admin/errors` lists and
188
+ * `postgresErrorGroupsStore()` upserts. Applied whether or not the app installs the sink, for
189
+ * every row's reason: turning it on must not be the deploy whose first error meets `42P01` —
190
+ * and a sink write that fails is only logged, so that outage would be silent. Same leaf subpath.
191
+ */
192
+ Object.freeze({
193
+ pkg: '@ultimat3/admin',
194
+ tables: Object.freeze([ERROR_GROUPS_TABLE]),
195
+ ddl: Object.freeze([SQL_ERROR_GROUPS_TABLE]),
196
+ }),
197
+ /**
198
+ * `failureCapture`'s bundles — what `postgresFailureStore()` writes on a 5xx and `x repro` reads.
199
+ * Applied whether or not capture is on, for every row's reason: turning it on must not be the
200
+ * deploy whose first failure meets `42P01`, and a capture write that fails is only logged.
201
+ */
202
+ Object.freeze({
203
+ pkg: '@ultimat3/db',
204
+ tables: Object.freeze([FAILURES_TABLE]),
205
+ ddl: Object.freeze([SQL_FAILURES_TABLE]),
206
+ }),
140
207
  /**
141
208
  * `mcpConfirmations`' pending rows — what `postgresConfirmationStore()` reads and writes. Without
142
209
  * this row the first gated `tools/call` met `42P01`. Through `@ultimat3/mcp/schema`, a leaf
@@ -77,3 +77,22 @@ export async function localiseCatalogs(
77
77
  return { ...file, contents: serializeCatalog(seedCatalog(loadCatalog(generated))) };
78
78
  });
79
79
  }
80
+
81
+ /**
82
+ * `--locales` when the caller named any, and otherwise every locale the app already has a catalog
83
+ * for: a key written to `en` alone in an app that also ships `es` renders ⟦key⟧ for every Spanish
84
+ * reader. A stem that is not a locale tag is some other JSON file, and not a catalog to write to.
85
+ */
86
+ export async function namedOrAppLocales(
87
+ root: string,
88
+ named: readonly string[],
89
+ ): Promise<readonly string[]> {
90
+ if (named.length > 0) return named;
91
+ return (await catalogLocales(root)).filter((stem) => {
92
+ try {
93
+ return Intl.getCanonicalLocales(stem).length === 1;
94
+ } catch {
95
+ return false;
96
+ }
97
+ });
98
+ }
@@ -0,0 +1,156 @@
1
+ // `x g chart-alerts`: the chart's two monitoring templates written into an app whose chart predates
2
+ // them, plus the `values.yaml` blocks it lacks — appended, never rewritten, so a deliberate
3
+ // `enabled: false` stays. `--print` is the same rule table as plain Prometheus rule groups, for a
4
+ // rule loader outside Kubernetes. No `<name>`: an app has one chart, and `Chart.yaml` names it.
5
+
6
+ import { stringField, UltimateError } from '@ultimat3/core';
7
+ import { generateSpec } from './cmd-generate-spec';
8
+ import type { CommandContext } from './command';
9
+ import { invocationOf } from './command';
10
+ import { BadFlagError } from './errors';
11
+ import { generate } from './generate-files';
12
+ import type { Generator } from './generate-kinds';
13
+ import { containedPath, planWrites, writeFiles } from './generate-write';
14
+ import { msg } from './messages';
15
+ import type { CommandResult } from './output';
16
+ import type { ParsedArgs } from './parse';
17
+ import { flagBool } from './parse';
18
+ import type { GeneratedFile } from './templates';
19
+ import { ALERTS_VALUES, SERVICE_MONITOR_VALUES } from './templates/scaffold-helm';
20
+ import { alertRuleGroups, CHART_ALERTS } from './templates/scaffold-helm-alerts';
21
+
22
+ export const CHART_FILE = 'docker/helm/Chart.yaml';
23
+ export const VALUES_FILE = 'docker/helm/values.yaml';
24
+
25
+ /** A chart is not there to write into. Its fix is the half that needs none. */
26
+ export class ChartMissingError extends UltimateError {
27
+ constructor(reason: string) {
28
+ super({
29
+ code: 'X_CHART_MISSING',
30
+ cause: `${CHART_FILE} ${reason}, so there is no chart for the monitoring templates to join`,
31
+ fix: 'x g chart-alerts --print --json',
32
+ });
33
+ }
34
+ }
35
+
36
+ /** `--print` is this generator's, and on any other one it would be a flag accepted and ignored. */
37
+ export function refuseChartAlertsFlags(kind: Generator, args: ParsedArgs): void {
38
+ if (kind === 'chart-alerts' || !args.flags.has('print')) return;
39
+ throw new BadFlagError({
40
+ flag: 'print',
41
+ command: `g ${kind}`,
42
+ reason: "--print is the chart-alerts generator's",
43
+ fix: 'x g chart-alerts --print',
44
+ });
45
+ }
46
+
47
+ /** The flags this generator reads; any other set to something but its default is refused. */
48
+ const READS = new Set(['print', 'force', 'dry-run']);
49
+
50
+ function refuseUnreadFlags(args: ParsedArgs): void {
51
+ const named = args.positionals[1];
52
+ if (named !== undefined) {
53
+ throw new BadFlagError({
54
+ flag: 'name',
55
+ command: 'g chart-alerts',
56
+ reason: `takes no <name> and got "${named}": an app has one chart, and Chart.yaml names it`,
57
+ fix: 'x g chart-alerts',
58
+ });
59
+ }
60
+ for (const flag of generateSpec.flags ?? []) {
61
+ if (READS.has(flag.name)) continue;
62
+ const value = args.flags.get(flag.name);
63
+ if (value === undefined || value === flag.default) continue;
64
+ throw new BadFlagError({
65
+ flag: flag.name,
66
+ command: 'g chart-alerts',
67
+ reason: `the chart-alerts generator does not read --${flag.name}, so it would change nothing`,
68
+ fix: 'x g chart-alerts',
69
+ });
70
+ }
71
+ }
72
+
73
+ /** The chart's `name`, which its helpers (`<name>.fullname`, `<name>.labels`) are named after. */
74
+ export async function chartName(root: string): Promise<string> {
75
+ const file = Bun.file(containedPath(root, CHART_FILE));
76
+ if (!(await file.exists())) throw new ChartMissingError('does not exist');
77
+ let parsed: unknown;
78
+ try {
79
+ parsed = Bun.YAML.parse(await file.text());
80
+ } catch {
81
+ throw new ChartMissingError('does not parse as YAML');
82
+ }
83
+ const name = stringField(parsed, 'name');
84
+ if (name === undefined || !/^[a-z0-9][a-z0-9-]*$/.test(name)) {
85
+ throw new ChartMissingError('declares no chart name of lowercase letters, digits and hyphens');
86
+ }
87
+ return name;
88
+ }
89
+
90
+ /**
91
+ * The blocks `values.yaml` lacks, by top-level key. A key already there is the app's — whatever it
92
+ * says — and the templates read a missing one as `auto`, so a values file this never touches works.
93
+ */
94
+ export function missingValueBlocks(values: string): string {
95
+ const has = (key: string) => new RegExp(`^${key}\\s*:`, 'm').test(values);
96
+ return [
97
+ ...(has('serviceMonitor') ? [] : [SERVICE_MONITOR_VALUES]),
98
+ ...(has('alerts') ? [] : [ALERTS_VALUES]),
99
+ ].join('');
100
+ }
101
+
102
+ /** `values` with `blocks` appended after one blank line. */
103
+ const appended = (values: string, blocks: string): string =>
104
+ `${values.trimEnd()}\n\n${blocks.trimEnd()}\n`;
105
+
106
+ export async function runChartAlerts(ctx: CommandContext, root: string): Promise<CommandResult> {
107
+ refuseUnreadFlags(ctx.args);
108
+ if (flagBool(ctx.args, 'print')) {
109
+ const rules = alertRuleGroups();
110
+ return {
111
+ ok: true,
112
+ command: 'g',
113
+ summary: msg('cli.generate.chartAlertsPrinted', { count: CHART_ALERTS.length }),
114
+ data: { rules },
115
+ lines: rules.trimEnd().split('\n'),
116
+ };
117
+ }
118
+ const name = await chartName(root);
119
+ const force = flagBool(ctx.args, 'force');
120
+ // Without --force a template already there is kept, never a conflict: it is the app's, and this
121
+ // run's job is the files the chart lacks. --force rewrites both from the current table.
122
+ const files = generate({ kind: 'chart-alerts', name }).map(
123
+ (file): GeneratedFile =>
124
+ force || typeof file.contents !== 'string'
125
+ ? file
126
+ : { path: file.path, contents: file.contents, merge: 'if-absent' },
127
+ );
128
+ const valuesPath = containedPath(root, VALUES_FILE);
129
+ const valuesFile = Bun.file(valuesPath);
130
+ const values = (await valuesFile.exists()) ? await valuesFile.text() : undefined;
131
+ const blocks = values === undefined ? '' : missingValueBlocks(values);
132
+ const edited = blocks === '' ? [] : [VALUES_FILE];
133
+ const invocation = `${invocationOf(ctx, 'g')} chart-alerts`;
134
+ const dryRun = flagBool(ctx.args, 'dry-run');
135
+ const report = dryRun
136
+ ? await planWrites(root, files, force, invocation)
137
+ : await writeFiles(root, files, force, invocation);
138
+ if (!dryRun && values !== undefined && blocks !== '') {
139
+ await Bun.write(valuesPath, appended(values, blocks));
140
+ }
141
+ const touched = [...report.written, ...edited];
142
+ // Zero files is a run like any other: a chart that holds both templates and both values blocks.
143
+ const summary = msg(dryRun ? 'cli.generate.planned' : 'cli.generate.wrote', {
144
+ count: touched.length,
145
+ kind: 'chart-alerts',
146
+ name,
147
+ });
148
+ return {
149
+ ok: report.conflicts.length === 0,
150
+ command: 'g',
151
+ summary,
152
+ data: { files: touched, edits: edited, ...(dryRun ? { dryRun: true } : {}) },
153
+ lines: touched.map((path) => msg('cli.file.added', { path })),
154
+ findings: report.conflicts,
155
+ };
156
+ }
@@ -0,0 +1,65 @@
1
+ // `x g query` and `x g backfill` read a table, so a slice with no `entity.ts` is refused rather than
2
+ // handed one. `x g query order` wrote 14 files and `next: x db gen "create orders"`: a read asked
3
+ // for and a table delivered. Only `x g entity` and `x g resource` create a feature's data.
4
+
5
+ import { existsSync } from 'node:fs'; // why: a synchronous file probe, as the slice's own guard.
6
+ import { UltimateError } from '@ultimat3/core';
7
+ import { commentSafe } from './comment-safe';
8
+ import { closestSlice, slicesBeside } from './generate-feature';
9
+ import type { Generator } from './generate-kinds';
10
+ import { containedPath } from './generate-write';
11
+ import { quoteArg } from './shell-quote';
12
+
13
+ /** The generators whose whole body reads a table the slice declares. */
14
+ const READS_A_TABLE: ReadonlySet<Generator> = new Set<Generator>(['query', 'backfill']);
15
+
16
+ /** Declared beside its one thrower: `errors.ts` sits at its 500-line ceiling. */
17
+ export class FeatureRequiredError extends UltimateError {
18
+ constructor(input: { readonly cause: string; readonly fix: string }) {
19
+ super({ code: 'X_FEATURE_REQUIRED', cause: input.cause, fix: input.fix });
20
+ }
21
+ }
22
+
23
+ export interface SliceToRead {
24
+ readonly kind: Generator;
25
+ /** The name as the caller spelled it, for the line the fix reproduces. */
26
+ readonly name: string;
27
+ readonly feature: string | undefined;
28
+ /** `sliceDir(surface, kebab(feature ?? name))`, relative to the app root. */
29
+ readonly slice: string;
30
+ /** `reproducedFlags`' spelling of what the caller set: a fix never loses `--live`. */
31
+ readonly flags: readonly string[];
32
+ }
33
+
34
+ /**
35
+ * Refuses a read with no table to read, for the two kinds whose body needs one. Runs after
36
+ * `assertFeatureExists`, so a `--feature` reaching here names a directory that is there.
37
+ *
38
+ * Named flags stay out of the leading line's `--feature`, which this fix supplies itself.
39
+ */
40
+ export function assertSliceToRead(root: string, target: SliceToRead): void {
41
+ const { kind, name, feature, slice, flags } = target;
42
+ if (!READS_A_TABLE.has(kind)) return;
43
+ if (existsSync(containedPath(root, `${slice}/entity.ts`))) return;
44
+ if (feature !== undefined) {
45
+ throw new FeatureRequiredError({
46
+ cause: `x g ${kind} reads a table, and ${slice} holds no entity.ts — a generator writing into a slice never invents one`,
47
+ fix: `x g entity ${quoteArg(feature)}`,
48
+ });
49
+ }
50
+ const tables = slicesBeside(root, slice).filter((candidate) =>
51
+ existsSync(containedPath(root, `${slice}/../${candidate}/entity.ts`)),
52
+ );
53
+ const near = closestSlice(name, tables);
54
+ const named = quoteArg(name);
55
+ const listed = commentSafe(tables.join(', '));
56
+ throw new FeatureRequiredError({
57
+ cause: `x g ${kind} ${named} names no --feature and ${slice} holds no entity.ts (${tables.length === 0 ? 'no slice holds one yet' : `slices with one: ${listed}`}), and a read never creates the table it reads`,
58
+ fix:
59
+ near !== undefined
60
+ ? `${[`x g ${kind} ${named} --feature ${quoteArg(near)}`, ...flags].join(' ')} # or x g resource ${named} for a new table`
61
+ : tables.length === 0
62
+ ? `x g resource ${named} # a new table: x g ${kind} reads one, and none exists yet`
63
+ : `x g resource ${named} # a new table, or x g ${kind} ${named} --feature <slice> to read one of: ${listed}`,
64
+ });
65
+ }