@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,243 @@
1
+ // `x ci tested`'s predicate: was THIS push's tree already proved green by the pull request run that
2
+ // merged it? Pure over three injected GitHub reads, so every condition is a unit test; `ghTestedReads`
3
+ // is the one `gh api` binding. Any doubt answers `tested: false`, which runs everything.
4
+
5
+ import { renderFixShellArg, renderThrowable } from '@ultimat3/core';
6
+ import { t } from '@ultimat3/schema';
7
+ import type { GhHost } from './gh';
8
+ import { GhFailedError, ghJson } from './gh';
9
+ import type { GhRepo } from './gh-target';
10
+
11
+ /** Why the answer is what it is: `tested` when every condition held, else the first that did not. */
12
+ export const TESTED_CONDITIONS = [
13
+ 'tested',
14
+ 'subject',
15
+ 'merged',
16
+ 'fork',
17
+ 'tree',
18
+ 'run',
19
+ 'stale',
20
+ 'api',
21
+ // No commit to ask about: no `GITHUB_SHA`, and `git rev-parse HEAD` named none.
22
+ 'sha',
23
+ ] as const;
24
+
25
+ export type TestedCondition = (typeof TESTED_CONDITIONS)[number];
26
+
27
+ export interface TestedCommit {
28
+ /** The whole message's first line. */
29
+ readonly subject: string;
30
+ readonly tree: string;
31
+ }
32
+
33
+ export interface TestedPull {
34
+ readonly merged: boolean;
35
+ readonly mergeCommitSha: string | null;
36
+ readonly headSha: string;
37
+ /** `owner/name` of the head's repository; `null` when that repository is gone. */
38
+ readonly headRepo: string | null;
39
+ }
40
+
41
+ export interface TestedRun {
42
+ readonly id: number;
43
+ readonly status: string;
44
+ readonly conclusion: string | null;
45
+ readonly headRepo: string | null;
46
+ /** When the run last changed, which for a completed run is when it concluded. */
47
+ readonly completedAt: string;
48
+ }
49
+
50
+ /** The three questions the predicate asks GitHub, injected. */
51
+ export interface CiTestedReads {
52
+ readonly commit: (sha: string) => Promise<TestedCommit>;
53
+ readonly pull: (number: number) => Promise<TestedPull>;
54
+ /** The workflow's runs on one head commit, any event. */
55
+ readonly runs: (headSha: string) => Promise<readonly TestedRun[]>;
56
+ }
57
+
58
+ export interface CiTestedAnswer {
59
+ readonly tested: boolean;
60
+ readonly condition: TestedCondition;
61
+ readonly pr?: number;
62
+ readonly run?: number;
63
+ /** Only for `api`: the refusal, rendered. */
64
+ readonly detail?: string;
65
+ }
66
+
67
+ /**
68
+ * `(#N)` as the subject's LAST characters, which is how GitHub's squash and merge-commit subjects
69
+ * end. Mid-subject, or on a body line, it is a reference to a PR, never the PR that merged it.
70
+ */
71
+ export function mergedPullOf(subject: string): number | undefined {
72
+ const match = /\(#(\d+)\)$/.exec(subject);
73
+ return match === null || subject.includes('\n') ? undefined : Number(match[1]);
74
+ }
75
+
76
+ export async function ciTested(input: {
77
+ readonly sha: string;
78
+ readonly repo: string;
79
+ readonly maxAgeMs: number;
80
+ readonly now: number;
81
+ readonly reads: CiTestedReads;
82
+ }): Promise<CiTestedAnswer> {
83
+ const { sha, repo, reads } = input;
84
+ let pr: number | undefined;
85
+ try {
86
+ const pushed = await reads.commit(sha);
87
+ pr = mergedPullOf(pushed.subject);
88
+ if (pr === undefined) return { tested: false, condition: 'subject' };
89
+ const pull = await reads.pull(pr);
90
+ if (!pull.merged || pull.mergeCommitSha !== sha)
91
+ return { tested: false, condition: 'merged', pr };
92
+ // A fork's workflow file is the fork's to write: its green says nothing this repository trusts.
93
+ if (pull.headRepo !== repo) return { tested: false, condition: 'fork', pr };
94
+ const head = await reads.commit(pull.headSha);
95
+ if (head.tree !== pushed.tree) return { tested: false, condition: 'tree', pr };
96
+ const own = (await reads.runs(pull.headSha)).filter((run) => run.headRepo === repo);
97
+ // The newest run decides: a red run after a green one on the same head is the later verdict.
98
+ const newest = [...own].sort((a, b) => b.id - a.id)[0];
99
+ if (newest === undefined) return { tested: false, condition: 'run', pr };
100
+ if (newest.status !== 'completed' || newest.conclusion !== 'success') {
101
+ return { tested: false, condition: 'run', pr, run: newest.id };
102
+ }
103
+ const age = input.now - Date.parse(newest.completedAt);
104
+ if (!(age >= 0 && age <= input.maxAgeMs)) {
105
+ return { tested: false, condition: 'stale', pr, run: newest.id };
106
+ }
107
+ return { tested: true, condition: 'tested', pr, run: newest.id };
108
+ } catch (error) {
109
+ // Any read that did not answer widens the gate: a full run costs minutes, a wrong skip ships.
110
+ return {
111
+ tested: false,
112
+ condition: 'api',
113
+ ...(pr === undefined ? {} : { pr }),
114
+ detail: renderThrowable(error),
115
+ };
116
+ }
117
+ }
118
+
119
+ const COMMIT = t.object({ subject: t.string, tree: t.string });
120
+ const PULL = t.object({
121
+ merged: t.boolean,
122
+ mergeCommitSha: t.nullable(t.string),
123
+ headSha: t.string,
124
+ headRepo: t.nullable(t.string),
125
+ });
126
+ const RUNS = t.array(
127
+ t.object({
128
+ id: t.number,
129
+ status: t.string,
130
+ conclusion: t.nullable(t.string),
131
+ headRepo: t.nullable(t.string),
132
+ completedAt: t.string,
133
+ }),
134
+ );
135
+
136
+ /** One `gh` call's deadline: four calls stay well inside the `tested` job's five minutes. */
137
+ export const TESTED_CALL_TIMEOUT_MS = 30_000;
138
+
139
+ const killHard = (pid: number): void => {
140
+ try {
141
+ process.kill(pid, 'SIGKILL');
142
+ } catch {
143
+ // Already gone between the deadline and the signal.
144
+ }
145
+ };
146
+
147
+ /**
148
+ * `host`, with every call killed past `ms`: a stalled `gh api` must answer `tested: false` (the
149
+ * caller's `api` doubt), never hold the job until its `timeout-minutes` turns it red. The refusal
150
+ * is `X_GH_COMMAND_FAILED` — a call that did not answer is one more non-zero `gh`.
151
+ */
152
+ export function withDeadline(
153
+ host: GhHost,
154
+ ms: number,
155
+ kill: (pid: number) => void = killHard,
156
+ ): GhHost {
157
+ return {
158
+ cwd: host.cwd,
159
+ runner: (command, options) =>
160
+ new Promise((resolve, reject) => {
161
+ let pid: number | undefined;
162
+ const timer = setTimeout(() => {
163
+ if (pid !== undefined) kill(pid);
164
+ reject(
165
+ new GhFailedError({
166
+ label: command.slice(0, 3).join(' '),
167
+ code: -1,
168
+ detail: `did not answer within ${ms} ms and was killed`,
169
+ fix: 'x ci tested --json',
170
+ }),
171
+ );
172
+ }, ms);
173
+ host
174
+ .runner(command, {
175
+ ...options,
176
+ onSpawn: (spawned) => {
177
+ pid = spawned;
178
+ options.onSpawn?.(spawned);
179
+ },
180
+ })
181
+ .then(
182
+ (result) => {
183
+ clearTimeout(timer);
184
+ resolve(result);
185
+ },
186
+ (error: unknown) => {
187
+ clearTimeout(timer);
188
+ reject(error);
189
+ },
190
+ );
191
+ }),
192
+ };
193
+ }
194
+
195
+ /** The reads, through `gh api`: `--jq` projects each answer to exactly the fields above. */
196
+ export function ghTestedReads(host: GhHost, repo: GhRepo, workflow: string): CiTestedReads {
197
+ const api = `repos/${repo.slug}`;
198
+ const fix = `x ci tested --repo ${renderFixShellArg(repo.slug, '<owner/name>')} --json`;
199
+ return {
200
+ commit: (sha) =>
201
+ ghJson(
202
+ host,
203
+ [
204
+ 'api',
205
+ `${api}/commits/${sha}`,
206
+ '--jq',
207
+ '{subject: (.commit.message | split("\\n")[0]), tree: .commit.tree.sha}',
208
+ ],
209
+ COMMIT,
210
+ { label: `gh api commits/${sha}`, fix },
211
+ ),
212
+ pull: (number) =>
213
+ ghJson(
214
+ host,
215
+ [
216
+ 'api',
217
+ `${api}/pulls/${number}`,
218
+ '--jq',
219
+ '{merged, mergeCommitSha: .merge_commit_sha, headSha: .head.sha, headRepo: .head.repo.full_name}',
220
+ ],
221
+ PULL,
222
+ { label: `gh api pulls/${number}`, fix },
223
+ ),
224
+ runs: (headSha) =>
225
+ ghJson(
226
+ host,
227
+ [
228
+ 'api',
229
+ '-X',
230
+ 'GET',
231
+ `${api}/actions/workflows/${workflow}/runs`,
232
+ '-f',
233
+ `head_sha=${headSha}`,
234
+ '-f',
235
+ 'per_page=50',
236
+ '--jq',
237
+ '[.workflow_runs[] | {id, status, conclusion, headRepo: .head_repository.full_name, completedAt: .updated_at}]',
238
+ ],
239
+ RUNS,
240
+ { label: `gh api ${workflow} runs`, fix },
241
+ ),
242
+ };
243
+ }
@@ -0,0 +1,139 @@
1
+ // The fixed half of every customer client's `index.ts`: options, the problem error and the one
2
+ // sender each operation calls. Platform only — no import — and the same bytes in every client, so
3
+ // what a customer reviews once is what every regeneration ships.
4
+
5
+ export const CUSTOMER_RUNTIME = `/** Where the API lives, and how each request is authorised. */
6
+ export interface ClientOptions {
7
+ /** \`https://api.example.com\` — every operation's path is appended to it. */
8
+ readonly baseUrl: string;
9
+ /** Called before every request: the token or session the caller holds is the caller's to keep. */
10
+ readonly headers?:
11
+ | (() => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>)
12
+ | undefined;
13
+ /** The transport; the platform \`fetch\` when omitted. */
14
+ readonly fetch?: ((url: string, init: RequestInit) => Promise<Response>) | undefined;
15
+ }
16
+
17
+ export interface CallOptions {
18
+ readonly signal?: AbortSignal | undefined;
19
+ }
20
+
21
+ /** An RFC 9457 problem body, as the API answers a refusal. */
22
+ export interface Problem {
23
+ readonly type?: string | undefined;
24
+ readonly title?: string | undefined;
25
+ readonly status?: number | undefined;
26
+ readonly code?: string | undefined;
27
+ readonly detail?: string | undefined;
28
+ readonly cause?: string | undefined;
29
+ readonly fix?: string | undefined;
30
+ readonly requestId?: string | undefined;
31
+ }
32
+
33
+ /** A non-2xx answer: which operation, the status, and the problem body when there was one. */
34
+ export class ProblemError extends Error {
35
+ readonly operation: string;
36
+ readonly status: number;
37
+ readonly code: string | undefined;
38
+ readonly problem: Problem | undefined;
39
+
40
+ constructor(operation: string, status: number, problem: Problem | undefined) {
41
+ const code = problem?.code ?? /^urn:ultimate:error:(X_[A-Z0-9_]+)$/.exec(problem?.type ?? '')?.[1];
42
+ super(\`\${operation} answered \${String(status)}\${code === undefined ? '' : \` \${code}\`}\`);
43
+ this.name = 'ProblemError';
44
+ this.operation = operation;
45
+ this.status = status;
46
+ this.code = code;
47
+ this.problem = problem;
48
+ }
49
+ }
50
+
51
+ interface CallSpec {
52
+ readonly operation: string;
53
+ readonly method: string;
54
+ readonly path: string;
55
+ readonly pathParams: readonly string[];
56
+ readonly query: readonly string[];
57
+ readonly headers: readonly string[];
58
+ /** \`value\`: \`input.body\` is the body; a list: those input fields are. */
59
+ readonly body: 'none' | 'value' | readonly string[];
60
+ readonly empty: boolean;
61
+ }
62
+
63
+ /** The longest \`Retry-After\` waited out; a longer one is the caller's to schedule. */
64
+ const RETRY_AFTER_MAX_MS = 60_000;
65
+
66
+ const read = (input: object, key: string): unknown =>
67
+ Object.hasOwn(input, key) ? (input as Readonly<Record<string, unknown>>)[key] : undefined;
68
+
69
+ function retryAfterMs(response: Response): number | undefined {
70
+ const raw = response.headers.get('retry-after');
71
+ if (raw === null) return undefined;
72
+ const seconds = Number(raw);
73
+ const ms = raw.trim() !== '' && Number.isFinite(seconds) ? seconds * 1000 : Date.parse(raw) - Date.now();
74
+ return Number.isFinite(ms) && ms >= 0 && ms <= RETRY_AFTER_MAX_MS ? ms : undefined;
75
+ }
76
+
77
+ function wait(ms: number, signal: AbortSignal | undefined): Promise<void> {
78
+ return new Promise((resolve, reject) => {
79
+ if (signal?.aborted === true) return reject(signal.reason);
80
+ const timer = setTimeout(resolve, ms);
81
+ signal?.addEventListener(
82
+ 'abort',
83
+ () => {
84
+ clearTimeout(timer);
85
+ reject(signal.reason);
86
+ },
87
+ { once: true },
88
+ );
89
+ });
90
+ }
91
+
92
+ async function problemOf(response: Response): Promise<Problem | undefined> {
93
+ try {
94
+ const body: unknown = await response.json();
95
+ return typeof body === 'object' && body !== null ? (body as Problem) : undefined;
96
+ } catch {
97
+ // Not JSON: the status alone is the answer, and the error says so with no problem body.
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ function sender(options: ClientOptions) {
103
+ const transport =
104
+ options.fetch ?? ((url: string, init: RequestInit) => fetch(url, { ...init, signal: init.signal ?? null }));
105
+ return async (spec: CallSpec, input: object, call: CallOptions): Promise<unknown> => {
106
+ let path = spec.path;
107
+ for (const name of spec.pathParams) {
108
+ path = path.replace(\`{\${name}}\`, encodeURIComponent(String(read(input, name))));
109
+ }
110
+ const url = new URL(\`\${options.baseUrl.replace(/\\/$/, '')}\${path}\`);
111
+ for (const name of spec.query) {
112
+ const value = read(input, name);
113
+ if (value === undefined) continue;
114
+ for (const item of Array.isArray(value) ? value : [value]) url.searchParams.append(name, String(item));
115
+ }
116
+ const headers: Record<string, string> = { accept: 'application/json', ...(await options.headers?.()) };
117
+ for (const name of spec.headers) {
118
+ const value = read(input, name);
119
+ if (value !== undefined) headers[name] = String(value);
120
+ }
121
+ let body: string | null = null;
122
+ if (spec.body === 'value') body = JSON.stringify(read(input, 'body'));
123
+ else if (typeof spec.body !== 'string') {
124
+ const fields = spec.body.filter((key) => read(input, key) !== undefined);
125
+ body = JSON.stringify(Object.fromEntries(fields.map((key) => [key, read(input, key)])));
126
+ }
127
+ if (body !== null) headers['content-type'] = 'application/json';
128
+ for (let attempt = 0; ; attempt += 1) {
129
+ const response = await transport(url.toString(), { method: spec.method, headers, body, signal: call.signal ?? null });
130
+ if (response.ok) return spec.empty || response.status === 204 ? undefined : await response.json();
131
+ // A 429 or 503 with a Retry-After is waited out once; a second refusal is the caller's.
132
+ const delay = attempt === 0 && (response.status === 429 || response.status === 503) ? retryAfterMs(response) : undefined;
133
+ if (delay === undefined) throw new ProblemError(spec.operation, response.status, await problemOf(response));
134
+ await response.body?.cancel();
135
+ await wait(delay, call.signal);
136
+ }
137
+ };
138
+ }
139
+ `;
@@ -0,0 +1,108 @@
1
+ // The customer client: what a third party's TypeScript calls the app's published API with. Zero
2
+ // imports beyond the platform — `fetch`, types, and a `ProblemError` read from the RFC 9457 body —
3
+ // so the directory is publishable as it is. `rpc` stays the client inside the app's own repo.
4
+
5
+ import { CUSTOMER_RUNTIME } from './client-emit-customer-runtime';
6
+ import type { DocumentNames } from './client-emit-types';
7
+ import { documentNames, propertyKey, typeText } from './client-emit-types';
8
+ import type { ClientOperation, CompiledDocument } from './openapi-compile-ir';
9
+
10
+ export interface CustomerFiles {
11
+ readonly 'index.ts': string;
12
+ readonly 'types.ts': string;
13
+ }
14
+
15
+ function typesFile(document: CompiledDocument, names: DocumentNames): string {
16
+ const lines: string[] = [
17
+ `// The types of ${document.title} ${document.version}, one per schema the operations reach.`,
18
+ '',
19
+ ];
20
+ for (const [name, type] of document.types) {
21
+ lines.push(`export type ${names.component(name)} = ${typeText(type, names.component)};`);
22
+ }
23
+ for (const op of document.operations) {
24
+ const types = names.operation.get(op.name);
25
+ for (const named of [types?.request, types?.response]) {
26
+ if (named?.text !== undefined) lines.push(`export type ${named.name} = ${named.text};`);
27
+ }
28
+ }
29
+ return `${lines.join('\n')}\n`;
30
+ }
31
+
32
+ /** The field names a `fields` body sends, its `$ref` followed into the document's types. */
33
+ export function bodyKeys(document: CompiledDocument, op: ClientOperation): readonly string[] {
34
+ const type = op.body?.type;
35
+ const target = type?.kind === 'ref' ? document.types.get(type.name) : type;
36
+ return target?.kind === 'object' ? target.fields.map((field) => field.name) : [];
37
+ }
38
+
39
+ /** The call one operation makes: where each parameter goes, and what the body is. */
40
+ function callSpec(document: CompiledDocument, op: ClientOperation): string {
41
+ const where = (place: 'path' | 'query' | 'header'): string =>
42
+ JSON.stringify(op.parameters.filter((p) => p.in === place).map((p) => p.name));
43
+ const body =
44
+ op.body === undefined
45
+ ? "'none'"
46
+ : op.body.mode === 'value'
47
+ ? "'value'"
48
+ : JSON.stringify(bodyKeys(document, op));
49
+ return `{ operation: ${JSON.stringify(op.name)}, method: ${JSON.stringify(op.method)}, path: ${JSON.stringify(op.path)}, pathParams: ${where('path')}, query: ${where('query')}, headers: ${where('header')}, body: ${body}, empty: ${String(op.response === undefined)} }`;
50
+ }
51
+
52
+ function operationLine(
53
+ document: CompiledDocument,
54
+ op: ClientOperation,
55
+ names: DocumentNames,
56
+ ): string {
57
+ const types = names.operation.get(op.name);
58
+ const request = types?.request === undefined ? undefined : `T.${types.request.name}`;
59
+ const response = types?.response === undefined ? 'void' : `T.${types.response.name}`;
60
+ const doc = op.summary === undefined ? '' : ` /** ${op.summary.replace(/\*\//g, '* /')} */\n`;
61
+ const input = request === undefined ? 'input: Record<string, never> = {}' : `input: ${request}`;
62
+ return `${doc} ${propertyKey(op.name)}: (${input}, call: CallOptions = {}): Promise<${response}> =>\n send(${callSpec(document, op)}, input, call) as Promise<${response}>,`;
63
+ }
64
+
65
+ /** Both files' text, unstamped: the command adds the header with the source hash. */
66
+ export function emitCustomer(document: CompiledDocument): CustomerFiles {
67
+ const names = documentNames(document);
68
+ const refusals = document.operations.map(
69
+ (op) =>
70
+ ` ${propertyKey(op.name)}: ${op.refusals.length === 0 ? 'never' : op.refusals.map((code) => JSON.stringify(code)).join(' | ')};`,
71
+ );
72
+ const declared = document.operations.map(
73
+ (op) => ` ${propertyKey(op.name)}: ${JSON.stringify(op.refusals)},`,
74
+ );
75
+ const index = [
76
+ `// The client for ${document.title} ${document.version}: one method per published operation.`,
77
+ "import type * as T from './types';",
78
+ '',
79
+ CUSTOMER_RUNTIME,
80
+ '/** The codes each operation declares it may refuse with; `never` when it declares none. */',
81
+ 'export interface Refusals {',
82
+ ...refusals,
83
+ '}',
84
+ '',
85
+ 'const DECLARED: { readonly [K in keyof Refusals]: readonly string[] } = {',
86
+ ...declared,
87
+ '};',
88
+ '',
89
+ '/** The declared refusal `error` carries for `operation`, narrowed; `undefined` for anything else. */',
90
+ 'export function refusalOf<K extends keyof Refusals>(error: unknown, operation: K): Refusals[K] | undefined {',
91
+ ' if (!(error instanceof ProblemError) || error.operation !== operation) return undefined;',
92
+ ' const code = error.code;',
93
+ ' return code !== undefined && DECLARED[operation].includes(code) ? (code as Refusals[K]) : undefined;',
94
+ '}',
95
+ '',
96
+ '/** One method per operation, named by its `operationId`. */',
97
+ 'export function createClient(options: ClientOptions) {',
98
+ ' const send = sender(options);',
99
+ ' return {',
100
+ ...document.operations.map((op) => operationLine(document, op, names)),
101
+ ' };',
102
+ '}',
103
+ '',
104
+ 'export type Client = ReturnType<typeof createClient>;',
105
+ '',
106
+ ].join('\n');
107
+ return { 'index.ts': index, 'types.ts': typesFile(document, names) };
108
+ }
@@ -0,0 +1,72 @@
1
+ // The `drift` step's client rail: every file `x client` wrote, read back against its own stamp. A
2
+ // source document whose content moved is `X_CLIENT_STALE`; a body whose bytes moved is
3
+ // `X_CLIENT_EDITED`. The stamp is the convention's build error — no file is trusted on its name.
4
+
5
+ // why: Bun ships no path-join primitive.
6
+ import { join } from 'node:path';
7
+ import { ERROR_DOCS_URL, timingSafeEqual } from '@ultimat3/core';
8
+ import { readStamp, STAMP_MARKER, sha256 } from './client-emit-header';
9
+ import { sourceHash } from './cmd-client-source';
10
+ import type { Finding } from './output';
11
+ import { eachSourceFile, isVendored } from './source-files';
12
+
13
+ /** Vendor clients live here; customer clients wherever `--out` put them inside the source globs. */
14
+ const CLIENT_GLOB = 'apps/*/clients/**/*.ts';
15
+
16
+ /** How much of a file is read to look for a stamp: the header is the first three lines. */
17
+ const HEAD_BYTES = 600;
18
+
19
+ async function candidates(root: string): Promise<readonly string[]> {
20
+ const paths = new Set<string>();
21
+ for await (const path of new Bun.Glob(CLIENT_GLOB).scan({ cwd: root, absolute: false })) {
22
+ if (!isVendored(path)) paths.add(path.replace(/\\/g, '/'));
23
+ }
24
+ for await (const path of eachSourceFile(root)) paths.add(path);
25
+ return [...paths].sort();
26
+ }
27
+
28
+ /** The source's hash now, or `undefined` when it is gone or no longer JSON. */
29
+ async function currentHash(root: string, source: string): Promise<string | undefined> {
30
+ const file = Bun.file(join(root, source));
31
+ if (source === '' || !(await file.exists())) return undefined;
32
+ try {
33
+ return sourceHash(JSON.parse(await file.text()));
34
+ } catch {
35
+ // Unparseable is "not the document the client was compiled from" — the stale finding says so.
36
+ return undefined;
37
+ }
38
+ }
39
+
40
+ export async function checkClientDrift(root: string): Promise<readonly Finding[]> {
41
+ const findings: Finding[] = [];
42
+ const hashes = new Map<string, string | undefined>();
43
+ for (const path of await candidates(root)) {
44
+ const file = Bun.file(join(root, path));
45
+ if (!(await file.slice(0, HEAD_BYTES).text()).includes(STAMP_MARKER)) continue;
46
+ const read = readStamp(await file.text());
47
+ if (read === undefined) continue;
48
+ const { stamp, body } = read;
49
+ const fix = `${stamp.command === '' ? 'x client --help' : stamp.command} --json`;
50
+ if (!timingSafeEqual(sha256(body), stamp.bodyHash)) {
51
+ findings.push({
52
+ code: 'X_CLIENT_EDITED',
53
+ cause: `${path} no longer matches the hash in its own stamp: it was edited by hand, and the next regeneration discards that edit`,
54
+ fix: `${fix} # then keep the change in a file of your own that wraps this client`,
55
+ docs: ERROR_DOCS_URL,
56
+ at: path,
57
+ });
58
+ continue;
59
+ }
60
+ if (!hashes.has(stamp.source)) hashes.set(stamp.source, await currentHash(root, stamp.source));
61
+ if (!timingSafeEqual(hashes.get(stamp.source) ?? '', stamp.sourceHash)) {
62
+ findings.push({
63
+ code: 'X_CLIENT_STALE',
64
+ cause: `${path} was compiled from a ${stamp.source} that has since changed, so it calls the old contract`,
65
+ fix,
66
+ docs: ERROR_DOCS_URL,
67
+ at: path,
68
+ });
69
+ }
70
+ }
71
+ return findings;
72
+ }
@@ -0,0 +1,67 @@
1
+ // The stamp every file `x client` writes opens with: the command that regenerates it, the source
2
+ // document and its hash, and the hash of the file's own body. The `drift` step reads it back — a
3
+ // source that moved is `X_CLIENT_STALE`, a body that moved is `X_CLIENT_EDITED`.
4
+
5
+ import { t } from '@ultimat3/schema';
6
+
7
+ export const STAMP_MARKER = '// @x-client ';
8
+
9
+ /** What a generated file says about itself. */
10
+ export interface ClientStamp {
11
+ /** The invocation that writes this file again, flags included. */
12
+ readonly command: string;
13
+ /** App-root-relative path of the document it was compiled from. */
14
+ readonly source: string;
15
+ readonly sourceHash: string;
16
+ readonly bodyHash: string;
17
+ }
18
+
19
+ const stampSchema = t.object({
20
+ command: t.string,
21
+ source: t.string,
22
+ sourceHash: t.string,
23
+ bodyHash: t.string,
24
+ });
25
+
26
+ export const sha256 = (text: string): string =>
27
+ new Bun.CryptoHasher('sha256').update(text).digest('hex');
28
+
29
+ const HEAD = (source: string): readonly string[] => [
30
+ '// biome-ignore-all format: generated by x client; its bytes are hashed in the stamp below',
31
+ `// GENERATED from ${source} by x client: never edit it, wrap it in a file of your own.`,
32
+ ];
33
+
34
+ /** `body` with its header: the stamp names the hash of exactly the bytes after it. */
35
+ export function stamped(stamp: Omit<ClientStamp, 'bodyHash'>, body: string): string {
36
+ const full: ClientStamp = { ...stamp, bodyHash: sha256(body) };
37
+ const ordered = {
38
+ command: full.command,
39
+ source: full.source,
40
+ sourceHash: full.sourceHash,
41
+ bodyHash: full.bodyHash,
42
+ };
43
+ return `${[...HEAD(stamp.source), `${STAMP_MARKER}${JSON.stringify(ordered)}`].join('\n')}\n${body}`;
44
+ }
45
+
46
+ /** The stamp and the body after it; `undefined` for a file `x client` did not write. */
47
+ export function readStamp(
48
+ text: string,
49
+ ): { readonly stamp: ClientStamp; readonly body: string } | undefined {
50
+ const at = text.indexOf(STAMP_MARKER);
51
+ if (at < 0 || at > 400) return undefined;
52
+ const end = text.indexOf('\n', at);
53
+ if (end < 0) return undefined;
54
+ let raw: unknown;
55
+ try {
56
+ raw = JSON.parse(text.slice(at + STAMP_MARKER.length, end));
57
+ } catch {
58
+ // A marker whose JSON does not parse is a hand edit of the stamp itself: the body hash below
59
+ // cannot be read, so the file is reported as edited by the caller rather than skipped.
60
+ return { stamp: { command: '', source: '', sourceHash: '', bodyHash: '' }, body: '' };
61
+ }
62
+ const parsed = stampSchema.safeParse(raw);
63
+ if (!('value' in parsed)) {
64
+ return { stamp: { command: '', source: '', sourceHash: '', bodyHash: '' }, body: '' };
65
+ }
66
+ return { stamp: parsed.value as ClientStamp, body: text.slice(end + 1) };
67
+ }