@nifrajs/cli 3.5.0 → 4.0.2

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 (404) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
package/src/contracts.ts CHANGED
@@ -6,6 +6,8 @@ import {
6
6
  snapshotProjectEvidence,
7
7
  } from "@nifrajs/core/evidence"
8
8
  import type { ReflectedRoute } from "@nifrajs/core/reflection"
9
+ import { BACKEND_APP_FILE } from "./app-files.ts"
10
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
9
11
 
10
12
  export interface ContractDigest {
11
13
  readonly request: string
@@ -26,18 +28,58 @@ function recordOf(value: unknown): Record<string, unknown> | undefined {
26
28
  : undefined
27
29
  }
28
30
 
29
- function canonical(value: unknown, key?: string): unknown {
30
- if (["description", "title", "default", "example", "examples"].includes(key ?? ""))
31
- return undefined
31
+ /** Annotation keywords: editing one is not a contract change. */
32
+ const ANNOTATIONS: ReadonlySet<string> = new Set([
33
+ "description",
34
+ "title",
35
+ "default",
36
+ "example",
37
+ "examples",
38
+ ])
39
+ /** Keywords whose value maps user-chosen names to schemas, so a key there is a name, never a keyword. */
40
+ const NAME_MAPS: ReadonlySet<string> = new Set([
41
+ "properties",
42
+ "patternProperties",
43
+ "$defs",
44
+ "definitions",
45
+ "dependentSchemas",
46
+ "dependentRequired",
47
+ "dependencies",
48
+ ])
49
+ /** Keywords whose value is instance data, compared whole. */
50
+ const DATA: ReadonlySet<string> = new Set(["const", "enum"])
51
+
52
+ function sortedRecord(
53
+ record: Record<string, unknown>,
54
+ entry: (name: string, item: unknown) => unknown,
55
+ ): Record<string, unknown> {
56
+ const entries: Array<[string, unknown]> = []
57
+ for (const name of Object.keys(record).sort()) {
58
+ const item = entry(name, record[name])
59
+ if (item !== undefined) entries.push([name, item])
60
+ }
61
+ // `fromEntries` defines each key, where an assignment would run the `__proto__` setter instead.
62
+ return Object.fromEntries(entries)
63
+ }
64
+
65
+ function canonicalData(value: unknown): unknown {
66
+ if (Array.isArray(value)) return value.map((item) => canonicalData(item))
67
+ const record = recordOf(value)
68
+ return record === undefined ? value : sortedRecord(record, (_name, item) => canonicalData(item))
69
+ }
70
+
71
+ function canonical(value: unknown): unknown {
32
72
  if (Array.isArray(value)) return value.map((item) => canonical(item))
33
73
  const record = recordOf(value)
34
74
  if (record === undefined) return value
35
- const out: Record<string, unknown> = {}
36
- for (const name of Object.keys(record).sort()) {
37
- const item = canonical(record[name], name)
38
- if (item !== undefined) out[name] = item
39
- }
40
- return out
75
+ return sortedRecord(record, (name, item) => {
76
+ if (ANNOTATIONS.has(name)) return undefined
77
+ if (DATA.has(name)) return canonicalData(item)
78
+ const names = NAME_MAPS.has(name) ? recordOf(item) : undefined
79
+ return names === undefined
80
+ ? canonical(item)
81
+ : sortedRecord(names, (_key, schema) => canonical(schema))
82
+ })
41
83
  }
42
84
 
43
85
  async function sha256(value: string): Promise<string> {
@@ -104,13 +146,13 @@ export async function buildContractsLock(
104
146
  )
105
147
  return {
106
148
  version: 1,
107
- routes: Object.fromEntries(Object.entries(routes).sort(([a], [b]) => a.localeCompare(b))),
149
+ routes: Object.fromEntries(Object.entries(routes).sort(([a], [b]) => codeUnitOrder(a, b))),
108
150
  }
109
151
  }
110
152
 
111
153
  async function loadBackend(cwd: string): Promise<unknown> {
112
- const backendPath = resolve(cwd, "backend.ts")
113
- if (!existsSync(backendPath)) throw new Error(`[nifra] no backend.ts in ${cwd}`)
154
+ const backendPath = resolve(cwd, BACKEND_APP_FILE)
155
+ if (!existsSync(backendPath)) throw new Error(`[nifra] no ${BACKEND_APP_FILE} in ${cwd}`)
114
156
  const loadedValue: unknown = await import(backendPath)
115
157
  const loaded = recordOf(loadedValue)
116
158
  if (loaded?.backend === undefined)
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `.nifra/db-audit.jsonl`: one line per `nifra db` call - what ran, never what came back. Each entry
3
+ * carries the SQL (secrets redacted), its fingerprint, the row count, the duration and the refusal
4
+ * code. The file is owner-only, never followed through a symlink, and rotates at 1 MB to
5
+ * `db-audit.1.jsonl`, so it stays bounded.
6
+ */
7
+
8
+ import { createHash } from "node:crypto"
9
+ import {
10
+ chmodSync,
11
+ closeSync,
12
+ constants,
13
+ existsSync,
14
+ lstatSync,
15
+ mkdirSync,
16
+ openSync,
17
+ readFileSync,
18
+ renameSync,
19
+ writeSync,
20
+ } from "node:fs"
21
+ import { resolve } from "node:path"
22
+ import type { DbRefusalCode } from "@nifrajs/mcp-db/engine"
23
+
24
+ export const DB_AUDIT_FILE = ".nifra/db-audit.jsonl"
25
+ export const DB_AUDIT_ROTATED_FILE = ".nifra/db-audit.1.jsonl"
26
+ export const DB_AUDIT_MAX_BYTES = 1024 * 1024
27
+
28
+ /** One audited call. */
29
+ export interface DbAuditEntry {
30
+ readonly at: string
31
+ readonly tool: "schema" | "query" | "explain" | "role"
32
+ readonly engine?: "sqlite" | "postgres" | undefined
33
+ /** The statement, secrets redacted. */
34
+ readonly sql?: string | undefined
35
+ /** Stable across literal values and whitespace, so repeated shapes group together. */
36
+ readonly fingerprint?: string | undefined
37
+ readonly table?: string | undefined
38
+ readonly ok: boolean
39
+ readonly code?: DbRefusalCode | undefined
40
+ readonly rowCount?: number | undefined
41
+ readonly durationMs: number
42
+ }
43
+
44
+ /** A statement's shape: literals become `?`, whitespace collapses, case folds; hashed. */
45
+ export function sqlFingerprint(sql: string): string {
46
+ const shape = sql
47
+ .replace(/--[^\n]*/g, " ")
48
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
49
+ .replace(/'(?:[^']|'')*'/g, "?")
50
+ .replace(/\b\d+(?:\.\d+)?\b/g, "?")
51
+ .replace(/\s+/g, " ")
52
+ .trim()
53
+ .toLowerCase()
54
+ return createHash("sha256").update(shape).digest("hex").slice(0, 16)
55
+ }
56
+
57
+ /** True when `path` exists and is a symlink: the audit never writes through one. */
58
+ function isSymlink(path: string): boolean {
59
+ try {
60
+ return lstatSync(path).isSymbolicLink()
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Append one entry. Returns false (and writes nothing) when `.nifra` or the log is a symlink or the
68
+ * write fails - an audit problem never fails the call it records.
69
+ */
70
+ export function appendDbAudit(root: string, entry: DbAuditEntry): boolean {
71
+ const dir = resolve(root, ".nifra")
72
+ const file = resolve(root, DB_AUDIT_FILE)
73
+ try {
74
+ if (isSymlink(dir)) return false
75
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
76
+ if (isSymlink(file)) return false
77
+ const line = `${JSON.stringify(entry)}\n`
78
+ if (existsSync(file) && lstatSync(file).size + Buffer.byteLength(line) > DB_AUDIT_MAX_BYTES) {
79
+ const rotated = resolve(root, DB_AUDIT_ROTATED_FILE)
80
+ if (isSymlink(rotated)) return false
81
+ renameSync(file, rotated)
82
+ }
83
+ const fd = openSync(
84
+ file,
85
+ constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW,
86
+ 0o600,
87
+ )
88
+ try {
89
+ writeSync(fd, line)
90
+ } finally {
91
+ closeSync(fd)
92
+ }
93
+ chmodSync(file, 0o600)
94
+ return true
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+
100
+ /** True for a line `appendDbAudit` wrote (its required fields, at least). */
101
+ export const isDbAuditEntry = (value: unknown): value is DbAuditEntry =>
102
+ typeof value === "object" &&
103
+ value !== null &&
104
+ "at" in value &&
105
+ typeof value.at === "string" &&
106
+ "tool" in value &&
107
+ typeof value.tool === "string" &&
108
+ "ok" in value &&
109
+ typeof value.ok === "boolean"
110
+
111
+ /** The newest `limit` entries, oldest first, across the current and the rotated file. */
112
+ export function readDbAudit(root: string, limit: number): DbAuditEntry[] {
113
+ const entries: DbAuditEntry[] = []
114
+ for (const name of [DB_AUDIT_ROTATED_FILE, DB_AUDIT_FILE]) {
115
+ const path = resolve(root, name)
116
+ if (!existsSync(path) || isSymlink(path)) continue
117
+ for (const line of readFileSync(path, "utf8").split("\n")) {
118
+ if (line.trim() === "") continue
119
+ try {
120
+ const parsed: unknown = JSON.parse(line)
121
+ if (isDbAuditEntry(parsed)) entries.push(parsed)
122
+ } catch {
123
+ // A torn last line from a killed writer is skipped, not fatal.
124
+ }
125
+ }
126
+ }
127
+ return entries.slice(-limit)
128
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * The `nifra db` subprocess. The parent (`db-tool.ts`) spawns a fresh one per call: it applies the
3
+ * project's `.env` files, imports `nifra.config.ts`, reports the declared deadline, opens the database,
4
+ * runs one operation, prints the answer and exits. The parent kills it at the deadline, so the deadline
5
+ * bounds the work and not only the response.
6
+ *
7
+ * Protocol: one JSON request on stdin; answers on stdout as lines prefixed with the request's token,
8
+ * so anything the config prints cannot be read as an answer.
9
+ */
10
+
11
+ import {
12
+ type DbPlan,
13
+ type DbRedaction,
14
+ type DbRefusal,
15
+ type DbRows,
16
+ type DbSchemaReport,
17
+ dbRefusal,
18
+ explainSqlite,
19
+ isDbRefusal,
20
+ openReadOnlySqlite,
21
+ querySqlite,
22
+ readSqliteSchema,
23
+ resolveSqliteFile,
24
+ } from "@nifrajs/mcp-db/engine"
25
+ import {
26
+ connectPostgres,
27
+ explainPostgres,
28
+ type PostgresRoleSql,
29
+ parsePostgresUrl,
30
+ postgresRoleSql,
31
+ queryPostgres,
32
+ readPostgresSchema,
33
+ } from "@nifrajs/mcp-db/postgres"
34
+ import { createDevFeed } from "@nifrajs/web/dev-feed"
35
+ import { isSensitiveFieldName } from "@nifrajs/web/zones"
36
+ import { type DevDatabase, loadDevDatabase } from "./db-config.ts"
37
+ import { CHILD_INPUT_MAX_BYTES, readBoundedStream } from "./mcp-io.ts"
38
+
39
+ /** The one operation a subprocess runs. */
40
+ export interface DbChildRequest {
41
+ readonly op: "schema" | "query" | "explain" | "role"
42
+ readonly sql?: string | undefined
43
+ readonly table?: string | undefined
44
+ readonly analyze?: boolean | undefined
45
+ }
46
+
47
+ /** What an operation produced, before the parent adds timing and the untrusted-data note. */
48
+ export type DbChildAnswer =
49
+ | {
50
+ readonly ok: true
51
+ readonly engine: DevDatabase["kind"]
52
+ readonly database: string
53
+ readonly durationMs: number
54
+ readonly schema?: DbSchemaReport
55
+ readonly rows?: DbRows
56
+ readonly plan?: DbPlan
57
+ readonly role?: Omit<PostgresRoleSql, "serverVersionNum"> | { readonly statements: [] }
58
+ }
59
+ | {
60
+ readonly ok: false
61
+ readonly engine?: DevDatabase["kind"] | undefined
62
+ readonly database?: string | undefined
63
+ readonly durationMs: number
64
+ readonly refusal: DbRefusal
65
+ }
66
+
67
+ /** The lines a subprocess writes, after its token. */
68
+ export type DbChildMessage =
69
+ | { readonly type: "ready"; readonly timeoutMs: number }
70
+ | { readonly type: "answer"; readonly answer: DbChildAnswer }
71
+
72
+ const lower = (name: string): string => name.toLowerCase()
73
+
74
+ /**
75
+ * The masked columns a query may not read (the credential-name classifier, plus `redactColumns`,
76
+ * minus `revealColumns`) and the dev feed's text redactor for every string value.
77
+ */
78
+ export function dbRedaction(
79
+ root: string,
80
+ db: DevDatabase,
81
+ env: Readonly<Record<string, string | undefined>> = process.env,
82
+ ): DbRedaction {
83
+ const redact = new Set(db.redactColumns.map(lower))
84
+ const reveal = new Set(db.revealColumns.map(lower))
85
+ const feed = createDevFeed({ root, persist: false, env })
86
+ return {
87
+ column: (name) =>
88
+ !reveal.has(lower(name)) && (isSensitiveFieldName(name) || redact.has(lower(name))),
89
+ text: (value) => feed.redact(value),
90
+ }
91
+ }
92
+
93
+ const QUERY_OFF = (): DbRefusal =>
94
+ dbRefusal("NIFRA_DB_QUERY_OFF", "devDatabase.query is false: only the schema tool is on")
95
+
96
+ async function runSqlite(
97
+ root: string,
98
+ request: DbChildRequest,
99
+ db: Extract<DevDatabase, { kind: "sqlite" }>,
100
+ redaction: DbRedaction,
101
+ ): Promise<Omit<Extract<DbChildAnswer, { ok: true }>, "durationMs"> | DbRefusal> {
102
+ if (request.op === "role") {
103
+ return { ok: true, engine: "sqlite", database: db.file, role: { statements: [] } }
104
+ }
105
+ if (request.op !== "schema" && !db.query) return QUERY_OFF()
106
+ const file = resolveSqliteFile(root, db.file, db.allowFiles)
107
+ if (isDbRefusal(file)) return file
108
+ let handle: Awaited<ReturnType<typeof openReadOnlySqlite>>
109
+ try {
110
+ handle = await openReadOnlySqlite(file)
111
+ } catch (error) {
112
+ return dbRefusal("NIFRA_DB_DRIVER", `could not open ${db.file}: ${String(error)}`)
113
+ }
114
+ try {
115
+ const base = { ok: true as const, engine: "sqlite" as const, database: db.file }
116
+ if (request.op === "schema") {
117
+ const schema = readSqliteSchema(handle, {
118
+ exclude: db.exclude,
119
+ redaction,
120
+ table: request.table,
121
+ })
122
+ return isDbRefusal(schema) ? schema : { ...base, schema }
123
+ }
124
+ if (request.op === "explain") {
125
+ const plan = explainSqlite(handle, request.sql ?? "", db)
126
+ return isDbRefusal(plan) ? plan : { ...base, plan }
127
+ }
128
+ const rows = querySqlite(handle, request.sql ?? "", { ...db, redaction })
129
+ return isDbRefusal(rows) ? rows : { ...base, rows }
130
+ } finally {
131
+ handle.close()
132
+ }
133
+ }
134
+
135
+ async function runPostgres(
136
+ request: DbChildRequest,
137
+ db: Extract<DevDatabase, { kind: "postgres" }>,
138
+ redaction: DbRedaction,
139
+ ): Promise<Omit<Extract<DbChildAnswer, { ok: true }>, "durationMs"> | DbRefusal> {
140
+ if ((request.op === "query" || request.op === "explain") && !db.query) return QUERY_OFF()
141
+ const target = parsePostgresUrl(db.url)
142
+ if (isDbRefusal(target)) return target
143
+ const database = `${target.host ?? target.socket}:${target.port}/${target.database}`
144
+ const client = connectPostgres(target, db)
145
+ if (isDbRefusal(client)) return client
146
+ const base = { ok: true as const, engine: "postgres" as const, database }
147
+ try {
148
+ if (request.op === "schema") {
149
+ const schema = await readPostgresSchema(client, {
150
+ ...db,
151
+ redaction,
152
+ table: request.table,
153
+ })
154
+ return isDbRefusal(schema) ? schema : { ...base, schema }
155
+ }
156
+ if (request.op === "role") {
157
+ const role = await postgresRoleSql(client, db)
158
+ if (isDbRefusal(role)) return role
159
+ return {
160
+ ...base,
161
+ role: { role: role.role, database: role.database, statements: role.statements },
162
+ }
163
+ }
164
+ if (request.op === "explain") {
165
+ const plan = await explainPostgres(client, request.sql ?? "", {
166
+ ...db,
167
+ redaction,
168
+ analyze: request.analyze === true,
169
+ })
170
+ return isDbRefusal(plan) ? plan : { ...base, plan }
171
+ }
172
+ const rows = await queryPostgres(client, request.sql ?? "", { ...db, redaction })
173
+ return isDbRefusal(rows) ? rows : { ...base, rows }
174
+ } finally {
175
+ await client.close({ timeout: 0 }).catch(() => {})
176
+ }
177
+ }
178
+
179
+ /** Run one operation against a loaded declaration. Every refusal message is redacted too. */
180
+ export async function runDbOperation(
181
+ root: string,
182
+ request: DbChildRequest,
183
+ db: DevDatabase,
184
+ ): Promise<DbChildAnswer> {
185
+ const started = performance.now()
186
+ const redaction = dbRedaction(root, db)
187
+ let outcome: Omit<Extract<DbChildAnswer, { ok: true }>, "durationMs"> | DbRefusal
188
+ try {
189
+ outcome =
190
+ db.kind === "sqlite"
191
+ ? await runSqlite(root, request, db, redaction)
192
+ : await runPostgres(request, db, redaction)
193
+ } catch (error) {
194
+ outcome = dbRefusal("NIFRA_DB_DRIVER", error instanceof Error ? error.message : String(error))
195
+ }
196
+ const durationMs = Math.round(performance.now() - started)
197
+ if (!isDbRefusal(outcome)) return { ...outcome, durationMs }
198
+ return {
199
+ ok: false,
200
+ engine: db.kind,
201
+ durationMs,
202
+ refusal: { ...outcome, message: redaction.text?.(outcome.message) ?? outcome.message },
203
+ }
204
+ }
205
+
206
+ const OPS = ["schema", "query", "explain", "role"] as const
207
+
208
+ /** Validate the parent's request (it crosses a process boundary). */
209
+ export function parseDbChildRequest(
210
+ text: string,
211
+ ): (DbChildRequest & { readonly token: string }) | undefined {
212
+ let value: unknown
213
+ try {
214
+ value = JSON.parse(text)
215
+ } catch {
216
+ return undefined
217
+ }
218
+ if (typeof value !== "object" || value === null) return undefined
219
+ const field = (key: string): unknown => Reflect.get(value, key)
220
+ const token = field("token")
221
+ const op = OPS.find((name) => name === field("op"))
222
+ const sql = field("sql")
223
+ const table = field("table")
224
+ const analyze = field("analyze")
225
+ if (typeof token !== "string" || !/^[0-9a-f-]{16,64}$/.test(token)) return undefined
226
+ if (op === undefined) return undefined
227
+ if (sql !== undefined && typeof sql !== "string") return undefined
228
+ if (table !== undefined && typeof table !== "string") return undefined
229
+ if (analyze !== undefined && typeof analyze !== "boolean") return undefined
230
+ return { token, op, sql, table, analyze }
231
+ }
232
+
233
+ /**
234
+ * Serve one request: read it from `input`, load the declaration, report the deadline, run the
235
+ * operation and write the answer through `write`, each line prefixed with the request's token.
236
+ * Returns false (writing nothing) for a malformed request.
237
+ */
238
+ export async function serveDbChild(
239
+ root: string,
240
+ input: ReadableStream<Uint8Array>,
241
+ write: (line: string) => Promise<void>,
242
+ ): Promise<boolean> {
243
+ const text = await readBoundedStream(input, CHILD_INPUT_MAX_BYTES)
244
+ const request = text.truncated ? undefined : parseDbChildRequest(text.text)
245
+ if (request === undefined) return false
246
+ const send = (message: DbChildMessage): Promise<void> =>
247
+ write(`${request.token} ${JSON.stringify(message)}\n`)
248
+ const started = performance.now()
249
+ let answer: DbChildAnswer
250
+ try {
251
+ const db = await loadDevDatabase(root)
252
+ if (isDbRefusal(db)) {
253
+ answer = { ok: false, durationMs: Math.round(performance.now() - started), refusal: db }
254
+ } else {
255
+ await send({ type: "ready", timeoutMs: db.timeoutMs })
256
+ answer = await runDbOperation(root, request, db)
257
+ }
258
+ } catch (error) {
259
+ answer = {
260
+ ok: false,
261
+ durationMs: Math.round(performance.now() - started),
262
+ refusal: dbRefusal("NIFRA_DB_DRIVER", error instanceof Error ? error.message : String(error)),
263
+ }
264
+ }
265
+ await send({ type: "answer", answer })
266
+ return true
267
+ }
268
+
269
+ if (import.meta.main) {
270
+ // Bound before the config loads, so nothing it patches can reach the protocol channel.
271
+ const stdout = process.stdout.write.bind(process.stdout)
272
+ const toStderr = (...args: unknown[]): void => {
273
+ process.stderr.write(`${args.map((arg) => String(arg)).join(" ")}\n`)
274
+ }
275
+ console.log = toStderr
276
+ console.info = toStderr
277
+ console.debug = toStderr
278
+ const served = await serveDbChild(
279
+ process.argv[2] ?? process.cwd(),
280
+ Bun.stdin.stream(),
281
+ (line) => new Promise((done) => stdout(line, () => done())),
282
+ )
283
+ if (!served) process.stderr.write("nifra db: invalid request\n")
284
+ process.exit(served ? 0 : 2)
285
+ }