@bevel-software/platform-core-backend 0.14.0 → 0.15.1

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 (385) hide show
  1. package/THIRD-PARTY-NOTICES.md +6 -6
  2. package/dist/core/catalog-cache-invalidation.d.ts +29 -0
  3. package/dist/core/catalog-cache-invalidation.d.ts.map +1 -0
  4. package/dist/core/catalog-cache-invalidation.js +71 -0
  5. package/dist/core/catalog-cache-invalidation.js.map +1 -0
  6. package/dist/core/create-core-server.d.ts.map +1 -1
  7. package/dist/core/create-core-server.js +99 -8
  8. package/dist/core/create-core-server.js.map +1 -1
  9. package/dist/core/create-core-services.d.ts +18 -1
  10. package/dist/core/create-core-services.d.ts.map +1 -1
  11. package/dist/core/create-core-services.js +96 -43
  12. package/dist/core/create-core-services.js.map +1 -1
  13. package/dist/modules/access/access-control.interface.d.ts +18 -6
  14. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.d.ts +26 -1
  16. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.js +213 -71
  18. package/dist/modules/access/access-control.service.js.map +1 -1
  19. package/dist/modules/access/access.routes.d.ts.map +1 -1
  20. package/dist/modules/access/access.routes.js +59 -7
  21. package/dist/modules/access/access.routes.js.map +1 -1
  22. package/dist/modules/access-model/access-grammar.d.ts +51 -1
  23. package/dist/modules/access-model/access-grammar.d.ts.map +1 -1
  24. package/dist/modules/access-model/access-grammar.js +95 -1
  25. package/dist/modules/access-model/access-grammar.js.map +1 -1
  26. package/dist/modules/access-model/group-files.d.ts.map +1 -1
  27. package/dist/modules/access-model/group-files.js +4 -1
  28. package/dist/modules/access-model/group-files.js.map +1 -1
  29. package/dist/modules/access-model/plugin-principals.d.ts +49 -0
  30. package/dist/modules/access-model/plugin-principals.d.ts.map +1 -0
  31. package/dist/modules/access-model/plugin-principals.js +125 -0
  32. package/dist/modules/access-model/plugin-principals.js.map +1 -0
  33. package/dist/modules/database/core-schema.d.ts +326 -0
  34. package/dist/modules/database/core-schema.d.ts.map +1 -1
  35. package/dist/modules/database/core-schema.js +52 -0
  36. package/dist/modules/database/core-schema.js.map +1 -1
  37. package/dist/modules/kb-sync/index.d.ts +6 -0
  38. package/dist/modules/kb-sync/index.d.ts.map +1 -0
  39. package/dist/modules/kb-sync/index.js +5 -0
  40. package/dist/modules/kb-sync/index.js.map +1 -0
  41. package/dist/modules/kb-sync/kb-sync.interface.d.ts +77 -0
  42. package/dist/modules/kb-sync/kb-sync.interface.d.ts.map +1 -0
  43. package/dist/modules/kb-sync/kb-sync.interface.js +2 -0
  44. package/dist/modules/kb-sync/kb-sync.interface.js.map +1 -0
  45. package/dist/modules/kb-sync/kb-sync.routes.d.ts +70 -0
  46. package/dist/modules/kb-sync/kb-sync.routes.d.ts.map +1 -0
  47. package/dist/modules/kb-sync/kb-sync.routes.js +135 -0
  48. package/dist/modules/kb-sync/kb-sync.routes.js.map +1 -0
  49. package/dist/modules/kb-sync/kb-sync.service.d.ts +45 -0
  50. package/dist/modules/kb-sync/kb-sync.service.d.ts.map +1 -0
  51. package/dist/modules/kb-sync/kb-sync.service.js +183 -0
  52. package/dist/modules/kb-sync/kb-sync.service.js.map +1 -0
  53. package/dist/modules/kb-sync/sync-auth.d.ts +59 -0
  54. package/dist/modules/kb-sync/sync-auth.d.ts.map +1 -0
  55. package/dist/modules/kb-sync/sync-auth.js +62 -0
  56. package/dist/modules/kb-sync/sync-auth.js.map +1 -0
  57. package/dist/modules/kb-sync/sync-payload.d.ts +32 -0
  58. package/dist/modules/kb-sync/sync-payload.d.ts.map +1 -0
  59. package/dist/modules/kb-sync/sync-payload.js +105 -0
  60. package/dist/modules/kb-sync/sync-payload.js.map +1 -0
  61. package/dist/modules/marketplace/git-http.routes.d.ts +42 -0
  62. package/dist/modules/marketplace/git-http.routes.d.ts.map +1 -0
  63. package/dist/modules/marketplace/git-http.routes.js +190 -0
  64. package/dist/modules/marketplace/git-http.routes.js.map +1 -0
  65. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts +23 -0
  66. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.d.ts.map +1 -0
  67. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js +59 -0
  68. package/dist/modules/marketplace/github-facade/github-facade-admin.routes.js.map +1 -0
  69. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts +59 -0
  70. package/dist/modules/marketplace/github-facade/github-facade-codes.store.d.ts.map +1 -0
  71. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js +96 -0
  72. package/dist/modules/marketplace/github-facade/github-facade-codes.store.js.map +1 -0
  73. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts +99 -0
  74. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.d.ts.map +1 -0
  75. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js +147 -0
  76. package/dist/modules/marketplace/github-facade/github-facade-credentials.service.js.map +1 -0
  77. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts +47 -0
  78. package/dist/modules/marketplace/github-facade/github-facade.routes.d.ts.map +1 -0
  79. package/dist/modules/marketplace/github-facade/github-facade.routes.js +228 -0
  80. package/dist/modules/marketplace/github-facade/github-facade.routes.js.map +1 -0
  81. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts +109 -0
  82. package/dist/modules/marketplace/github-facade/github-facade.service.d.ts.map +1 -0
  83. package/dist/modules/marketplace/github-facade/github-facade.service.js +172 -0
  84. package/dist/modules/marketplace/github-facade/github-facade.service.js.map +1 -0
  85. package/dist/modules/marketplace/github-facade/index.d.ts +6 -0
  86. package/dist/modules/marketplace/github-facade/index.d.ts.map +1 -0
  87. package/dist/modules/marketplace/github-facade/index.js +6 -0
  88. package/dist/modules/marketplace/github-facade/index.js.map +1 -0
  89. package/dist/modules/marketplace/index.d.ts +4 -0
  90. package/dist/modules/marketplace/index.d.ts.map +1 -0
  91. package/dist/modules/marketplace/index.js +4 -0
  92. package/dist/modules/marketplace/index.js.map +1 -0
  93. package/dist/modules/marketplace/marketplace-repo.service.d.ts +122 -0
  94. package/dist/modules/marketplace/marketplace-repo.service.d.ts.map +1 -0
  95. package/dist/modules/marketplace/marketplace-repo.service.js +300 -0
  96. package/dist/modules/marketplace/marketplace-repo.service.js.map +1 -0
  97. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -1
  98. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +4 -0
  99. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -1
  100. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +14 -8
  101. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -1
  102. package/dist/modules/mcp/oauth/oauth-consent.routes.js +13 -1
  103. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -1
  104. package/dist/modules/mcp/oauth/oauth-state.d.ts +13 -2
  105. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -1
  106. package/dist/modules/mcp/oauth/oauth-state.js +3 -1
  107. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -1
  108. package/dist/modules/plugins/compile/compile-marketplace.d.ts +97 -0
  109. package/dist/modules/plugins/compile/compile-marketplace.d.ts.map +1 -0
  110. package/dist/modules/plugins/compile/compile-marketplace.js +240 -0
  111. package/dist/modules/plugins/compile/compile-marketplace.js.map +1 -0
  112. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts +66 -0
  113. package/dist/modules/plugins/compile/marketplace-compiler.service.d.ts.map +1 -0
  114. package/dist/modules/plugins/compile/marketplace-compiler.service.js +132 -0
  115. package/dist/modules/plugins/compile/marketplace-compiler.service.js.map +1 -0
  116. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts +37 -0
  117. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.d.ts.map +1 -0
  118. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js +135 -0
  119. package/dist/modules/plugins/discovery/bundle-dialect/bundle.source.js.map +1 -0
  120. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts +45 -0
  121. package/dist/modules/plugins/discovery/bundle-dialect/registry.d.ts.map +1 -0
  122. package/dist/modules/plugins/discovery/bundle-dialect/registry.js +147 -0
  123. package/dist/modules/plugins/discovery/bundle-dialect/registry.js.map +1 -0
  124. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts +37 -0
  125. package/dist/modules/plugins/discovery/kb-plugin-source.d.ts.map +1 -0
  126. package/dist/modules/plugins/discovery/kb-plugin-source.js +107 -0
  127. package/dist/modules/plugins/discovery/kb-plugin-source.js.map +1 -0
  128. package/dist/modules/plugins/discovery/native.source.d.ts +15 -0
  129. package/dist/modules/plugins/discovery/native.source.d.ts.map +1 -0
  130. package/dist/modules/plugins/discovery/native.source.js +102 -0
  131. package/dist/modules/plugins/discovery/native.source.js.map +1 -0
  132. package/dist/modules/plugins/discovery/plugin-source.d.ts +96 -0
  133. package/dist/modules/plugins/discovery/plugin-source.d.ts.map +1 -0
  134. package/dist/modules/plugins/discovery/plugin-source.js +2 -0
  135. package/dist/modules/plugins/discovery/plugin-source.js.map +1 -0
  136. package/dist/modules/plugins/index.d.ts +10 -1
  137. package/dist/modules/plugins/index.d.ts.map +1 -1
  138. package/dist/modules/plugins/index.js +8 -0
  139. package/dist/modules/plugins/index.js.map +1 -1
  140. package/dist/modules/plugins/plugin-links.d.ts +66 -0
  141. package/dist/modules/plugins/plugin-links.d.ts.map +1 -0
  142. package/dist/modules/plugins/plugin-links.js +160 -0
  143. package/dist/modules/plugins/plugin-links.js.map +1 -0
  144. package/dist/modules/plugins/plugin-links.service.d.ts +114 -0
  145. package/dist/modules/plugins/plugin-links.service.d.ts.map +1 -0
  146. package/dist/modules/plugins/plugin-links.service.js +304 -0
  147. package/dist/modules/plugins/plugin-links.service.js.map +1 -0
  148. package/dist/modules/plugins/plugin-provision.service.d.ts +39 -3
  149. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -1
  150. package/dist/modules/plugins/plugin-provision.service.js +152 -52
  151. package/dist/modules/plugins/plugin-provision.service.js.map +1 -1
  152. package/dist/modules/plugins/plugin-rename.service.d.ts +69 -0
  153. package/dist/modules/plugins/plugin-rename.service.d.ts.map +1 -0
  154. package/dist/modules/plugins/plugin-rename.service.js +245 -0
  155. package/dist/modules/plugins/plugin-rename.service.js.map +1 -0
  156. package/dist/modules/plugins/plugins.contract.d.ts +27 -1
  157. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -1
  158. package/dist/modules/plugins/plugins.routes.d.ts +7 -1
  159. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -1
  160. package/dist/modules/plugins/plugins.routes.js +120 -12
  161. package/dist/modules/plugins/plugins.routes.js.map +1 -1
  162. package/dist/modules/plugins/plugins.service.d.ts +50 -3
  163. package/dist/modules/plugins/plugins.service.d.ts.map +1 -1
  164. package/dist/modules/plugins/plugins.service.js +108 -47
  165. package/dist/modules/plugins/plugins.service.js.map +1 -1
  166. package/dist/modules/settings/deployment-settings.service.d.ts +9 -0
  167. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -1
  168. package/dist/modules/settings/deployment-settings.service.js +82 -2
  169. package/dist/modules/settings/deployment-settings.service.js.map +1 -1
  170. package/dist/modules/settings/setup.routes.d.ts +26 -0
  171. package/dist/modules/settings/setup.routes.d.ts.map +1 -1
  172. package/dist/modules/settings/setup.routes.js +9 -1
  173. package/dist/modules/settings/setup.routes.js.map +1 -1
  174. package/dist/modules/skills/index.d.ts +1 -0
  175. package/dist/modules/skills/index.d.ts.map +1 -1
  176. package/dist/modules/skills/index.js +1 -0
  177. package/dist/modules/skills/index.js.map +1 -1
  178. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -1
  179. package/dist/modules/skills/pending-skills.service.js +9 -4
  180. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  181. package/dist/modules/skills/skill-access-requests.routes.d.ts +38 -0
  182. package/dist/modules/skills/skill-access-requests.routes.d.ts.map +1 -0
  183. package/dist/modules/skills/skill-access-requests.routes.js +168 -0
  184. package/dist/modules/skills/skill-access-requests.routes.js.map +1 -0
  185. package/dist/modules/skills/skills.contract.d.ts +31 -0
  186. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  187. package/dist/modules/skills/skills.routes.d.ts +28 -3
  188. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  189. package/dist/modules/skills/skills.routes.js +42 -3
  190. package/dist/modules/skills/skills.routes.js.map +1 -1
  191. package/dist/modules/skills/skills.service.d.ts +4 -0
  192. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  193. package/dist/modules/skills/skills.service.js +83 -22
  194. package/dist/modules/skills/skills.service.js.map +1 -1
  195. package/dist/modules/tool-auth/external-api-key.interface.d.ts +18 -1
  196. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -1
  197. package/dist/modules/tool-auth/external-api-key.interface.js +2 -1
  198. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -1
  199. package/dist/modules/tool-auth/external-api-key.service.d.ts +13 -4
  200. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -1
  201. package/dist/modules/tool-auth/external-api-key.service.js +26 -9
  202. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -1
  203. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +36 -0
  204. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  205. package/dist/modules/tool-manuals/mcp-json-discovery.js +130 -92
  206. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  207. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +6 -1
  208. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  209. package/dist/modules/tool-manuals/tool-manuals.service.js +18 -25
  210. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  211. package/dist/modules/workflow/git/git.service.d.ts +40 -2
  212. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  213. package/dist/modules/workflow/git/git.service.js +227 -54
  214. package/dist/modules/workflow/git/git.service.js.map +1 -1
  215. package/dist/modules/workflow/git/pull-request.service.d.ts +6 -0
  216. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  217. package/dist/modules/workflow/git/pull-request.service.js +8 -0
  218. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  219. package/dist/modules/workflow/workflow.service.d.ts +52 -1
  220. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  221. package/dist/modules/workflow/workflow.service.js +242 -9
  222. package/dist/modules/workflow/workflow.service.js.map +1 -1
  223. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts +35 -0
  224. package/dist/modules/workspace/startup/steps/plugin-manifests.step.d.ts.map +1 -0
  225. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js +121 -0
  226. package/dist/modules/workspace/startup/steps/plugin-manifests.step.js.map +1 -0
  227. package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -1
  228. package/dist/modules/workspace/startup/steps/seed-tree.js +46 -2
  229. package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -1
  230. package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -1
  231. package/dist/modules/workspace/startup/steps/template-files.step.js +112 -25
  232. package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -1
  233. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  234. package/dist/modules/workspace/workspace.routes.js +9 -9
  235. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  236. package/dist/modules/workspace/workspace.service.d.ts +33 -0
  237. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  238. package/dist/modules/workspace/workspace.service.js +110 -16
  239. package/dist/modules/workspace/workspace.service.js.map +1 -1
  240. package/dist/shared/domain-errors.d.ts +20 -0
  241. package/dist/shared/domain-errors.d.ts.map +1 -1
  242. package/dist/shared/domain-errors.js +29 -0
  243. package/dist/shared/domain-errors.js.map +1 -1
  244. package/dist/shared/fs-errors.d.ts +8 -0
  245. package/dist/shared/fs-errors.d.ts.map +1 -0
  246. package/dist/shared/fs-errors.js +11 -0
  247. package/dist/shared/fs-errors.js.map +1 -0
  248. package/dist/shared/fs-walk.d.ts +20 -6
  249. package/dist/shared/fs-walk.d.ts.map +1 -1
  250. package/dist/shared/fs-walk.js +35 -27
  251. package/dist/shared/fs-walk.js.map +1 -1
  252. package/dist/shared/kb-walk.d.ts +52 -0
  253. package/dist/shared/kb-walk.d.ts.map +1 -0
  254. package/dist/shared/kb-walk.js +76 -0
  255. package/dist/shared/kb-walk.js.map +1 -0
  256. package/dist/shared/path-order.d.ts +18 -0
  257. package/dist/shared/path-order.d.ts.map +1 -0
  258. package/dist/shared/path-order.js +35 -0
  259. package/dist/shared/path-order.js.map +1 -0
  260. package/dist/shared/ttl-cache.d.ts +28 -1
  261. package/dist/shared/ttl-cache.d.ts.map +1 -1
  262. package/dist/shared/ttl-cache.js +33 -1
  263. package/dist/shared/ttl-cache.js.map +1 -1
  264. package/kb-template/.bevelignore +1 -1
  265. package/kb-template/AGENTS.md +95 -30
  266. package/kb-template/Skills/.gitkeep +0 -0
  267. package/migrations/0005_github_facade.sql +25 -0
  268. package/migrations/meta/0005_snapshot.json +1717 -0
  269. package/migrations/meta/_journal.json +7 -0
  270. package/package.json +6 -6
  271. package/src/__tests__/kb-layout-config.test.ts +110 -0
  272. package/src/core/__tests__/catalog-cache-invalidation.test.ts +146 -0
  273. package/src/core/catalog-cache-invalidation.ts +92 -0
  274. package/src/core/create-core-server.ts +123 -7
  275. package/src/core/create-core-services.ts +162 -41
  276. package/src/modules/access/__tests__/access-control.invalidate-race.test.ts +86 -0
  277. package/src/modules/access/__tests__/access-control.service.test.ts +19 -3
  278. package/src/modules/access/__tests__/access-plugin-principals.test.ts +368 -0
  279. package/src/modules/access/__tests__/groups-admin.service.test.ts +8 -0
  280. package/src/modules/access/access-control.interface.ts +20 -9
  281. package/src/modules/access/access-control.service.ts +240 -78
  282. package/src/modules/access/access.routes.ts +96 -14
  283. package/src/modules/access-model/__tests__/access-grammar.test.ts +18 -0
  284. package/src/modules/access-model/__tests__/plugin-token-canonical.test.ts +40 -0
  285. package/src/modules/access-model/access-grammar.ts +114 -2
  286. package/src/modules/access-model/group-files.ts +4 -0
  287. package/src/modules/access-model/plugin-principals.ts +137 -0
  288. package/src/modules/database/core-schema.ts +54 -0
  289. package/src/modules/kb-sync/__tests__/kb-sync.routes.test.ts +297 -0
  290. package/src/modules/kb-sync/__tests__/kb-sync.service.test.ts +295 -0
  291. package/src/modules/kb-sync/__tests__/sync-auth.test.ts +118 -0
  292. package/src/modules/kb-sync/__tests__/sync-payload.test.ts +148 -0
  293. package/src/modules/kb-sync/index.ts +19 -0
  294. package/src/modules/kb-sync/kb-sync.interface.ts +79 -0
  295. package/src/modules/kb-sync/kb-sync.routes.ts +197 -0
  296. package/src/modules/kb-sync/kb-sync.service.ts +201 -0
  297. package/src/modules/kb-sync/sync-auth.ts +118 -0
  298. package/src/modules/kb-sync/sync-payload.ts +143 -0
  299. package/src/modules/marketplace/__tests__/github-facade.test.ts +443 -0
  300. package/src/modules/marketplace/__tests__/marketplace-git.test.ts +179 -0
  301. package/src/modules/marketplace/git-http.routes.ts +193 -0
  302. package/src/modules/marketplace/github-facade/github-facade-admin.routes.ts +78 -0
  303. package/src/modules/marketplace/github-facade/github-facade-codes.store.ts +145 -0
  304. package/src/modules/marketplace/github-facade/github-facade-credentials.service.ts +208 -0
  305. package/src/modules/marketplace/github-facade/github-facade.routes.ts +268 -0
  306. package/src/modules/marketplace/github-facade/github-facade.service.ts +221 -0
  307. package/src/modules/marketplace/github-facade/index.ts +25 -0
  308. package/src/modules/marketplace/index.ts +3 -0
  309. package/src/modules/marketplace/marketplace-repo.service.ts +337 -0
  310. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +3 -0
  311. package/src/modules/mcp/oauth/oauth-consent.routes.ts +24 -2
  312. package/src/modules/mcp/oauth/oauth-state.ts +15 -3
  313. package/src/modules/plugins/__tests__/bundle-dialect.e2e.test.ts +163 -0
  314. package/src/modules/plugins/__tests__/compile-marketplace.test.ts +294 -0
  315. package/src/modules/plugins/__tests__/plugin-index.service.test.ts +56 -12
  316. package/src/modules/plugins/__tests__/plugin-links.service.test.ts +287 -0
  317. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +205 -5
  318. package/src/modules/plugins/__tests__/plugin-rename.service.test.ts +394 -0
  319. package/src/modules/plugins/__tests__/plugins.routes.test.ts +80 -33
  320. package/src/modules/plugins/compile/compile-marketplace.ts +403 -0
  321. package/src/modules/plugins/compile/marketplace-compiler.service.ts +150 -0
  322. package/src/modules/plugins/discovery/__tests__/kb-plugin-source.test.ts +329 -0
  323. package/src/modules/plugins/discovery/bundle-dialect/bundle.source.ts +140 -0
  324. package/src/modules/plugins/discovery/bundle-dialect/registry.ts +175 -0
  325. package/src/modules/plugins/discovery/kb-plugin-source.ts +114 -0
  326. package/src/modules/plugins/discovery/native.source.ts +122 -0
  327. package/src/modules/plugins/discovery/plugin-source.ts +99 -0
  328. package/src/modules/plugins/index.ts +10 -0
  329. package/src/modules/plugins/plugin-links.service.ts +363 -0
  330. package/src/modules/plugins/plugin-links.ts +184 -0
  331. package/src/modules/plugins/plugin-provision.service.ts +160 -54
  332. package/src/modules/plugins/plugin-rename.service.ts +298 -0
  333. package/src/modules/plugins/plugins.contract.ts +28 -1
  334. package/src/modules/plugins/plugins.routes.ts +128 -11
  335. package/src/modules/plugins/plugins.service.ts +106 -53
  336. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +57 -0
  337. package/src/modules/settings/__tests__/setup.routes.sync-status.test.ts +77 -0
  338. package/src/modules/settings/deployment-settings.service.ts +95 -2
  339. package/src/modules/settings/setup.routes.ts +24 -0
  340. package/src/modules/skills/__tests__/skill-access-requests.routes.test.ts +128 -0
  341. package/src/modules/skills/__tests__/skills.service.test.ts +85 -0
  342. package/src/modules/skills/index.ts +1 -0
  343. package/src/modules/skills/pending-skills.service.ts +7 -5
  344. package/src/modules/skills/skill-access-requests.routes.ts +199 -0
  345. package/src/modules/skills/skills.contract.ts +32 -0
  346. package/src/modules/skills/skills.routes.ts +59 -3
  347. package/src/modules/skills/skills.service.ts +84 -22
  348. package/src/modules/tool-auth/external-api-key.interface.ts +20 -1
  349. package/src/modules/tool-auth/external-api-key.service.ts +31 -13
  350. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +24 -1
  351. package/src/modules/tool-manuals/__tests__/tool-manuals.cli.test.ts +2 -0
  352. package/src/modules/tool-manuals/mcp-json-discovery.ts +154 -88
  353. package/src/modules/tool-manuals/tool-manuals.service.ts +14 -23
  354. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
  355. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +1 -0
  356. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +2 -2
  357. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +119 -2
  358. package/src/modules/workflow/__tests__/workflow.service.sync.test.ts +437 -0
  359. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +3 -3
  360. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +47 -4
  361. package/src/modules/workflow/git/__tests__/git.service.syncFromRemote.test.ts +253 -0
  362. package/src/modules/workflow/git/git.service.ts +239 -56
  363. package/src/modules/workflow/git/pull-request.service.ts +9 -0
  364. package/src/modules/workflow/workflow.service.ts +260 -9
  365. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +6 -7
  366. package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +93 -0
  367. package/src/modules/workspace/__tests__/workspace.service.missing-branch.test.ts +28 -0
  368. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +42 -12
  369. package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +219 -4
  370. package/src/modules/workspace/startup/steps/plugin-manifests.step.ts +129 -0
  371. package/src/modules/workspace/startup/steps/seed-tree.ts +44 -2
  372. package/src/modules/workspace/startup/steps/template-files.step.ts +131 -27
  373. package/src/modules/workspace/workspace.routes.ts +9 -17
  374. package/src/modules/workspace/workspace.service.ts +107 -16
  375. package/src/shared/__tests__/filename.test.ts +0 -0
  376. package/src/shared/__tests__/fs-walk.test.ts +22 -1
  377. package/src/shared/__tests__/kb-walk.test.ts +107 -0
  378. package/src/shared/__tests__/path-order.test.ts +24 -0
  379. package/src/shared/__tests__/ttl-cache.test.ts +55 -0
  380. package/src/shared/domain-errors.ts +29 -0
  381. package/src/shared/fs-errors.ts +10 -0
  382. package/src/shared/fs-walk.ts +38 -23
  383. package/src/shared/kb-walk.ts +95 -0
  384. package/src/shared/path-order.ts +33 -0
  385. package/src/shared/ttl-cache.ts +33 -1
@@ -13,6 +13,8 @@ import { WorkflowDomainError } from '../../shared/domain-errors.js';
13
13
  import type { WorkspaceService } from '../workspace/workspace.service.js';
14
14
  import { pluginsWorkspaceId } from './plugins.service.js';
15
15
  import { PluginProvisionError, type PluginProvisionService } from './plugin-provision.service.js';
16
+ import { PluginLinkError, type PluginLinksService } from './plugin-links.service.js';
17
+ import { PluginRenameError, type PluginRenameService } from './plugin-rename.service.js';
16
18
  import type { JoinRequestsService } from './join-requests.service.js';
17
19
  import type {
18
20
  PluginCatalogEntry,
@@ -61,13 +63,121 @@ export function createPluginsRoutes(
61
63
  provision: PluginProvisionService,
62
64
  kbDirName: string,
63
65
  resolveUser: (req: express.Request) => Promise<AuthUser | null>,
66
+ /** Optional: a host without the link machinery simply has no link routes. */
67
+ links?: PluginLinksService,
68
+ /** Optional: a host without it has no rename route. */
69
+ rename?: PluginRenameService,
64
70
  ): express.Router {
65
71
  const router = express.Router();
66
72
 
73
+ /**
74
+ * Rename a plugin — its identifier, its display name, or both. The
75
+ * MANAGER's verb (write on the folder's access.md, the gate linking uses).
76
+ * An identifier change rewrites every grant naming the old identifier in
77
+ * the same commit; see `PluginRenameService`.
78
+ *
79
+ * PATCH /api/plugins/:name { name?, displayName? } → { name, displayName, rewritten }
80
+ */
81
+ if (rename) {
82
+ router.patch('/plugins/:name', async (req, res) => {
83
+ if (!req.userEmail) {
84
+ res.status(401).json({ error: 'Unauthenticated' });
85
+ return;
86
+ }
87
+ const user = await resolveUser(req);
88
+ if (!user) {
89
+ res.status(401).json({ error: 'Unauthenticated' });
90
+ return;
91
+ }
92
+ const body = (req.body ?? {}) as { name?: unknown; displayName?: unknown };
93
+ try {
94
+ res.json(await rename.rename(user, String(req.params.name), { name: body.name, displayName: body.displayName }));
95
+ } catch (err) {
96
+ if (err instanceof PluginRenameError) {
97
+ res.status(err.status).json({ error: err.message, ...err.payload });
98
+ return;
99
+ }
100
+ if (err instanceof WorkflowDomainError) {
101
+ res.status(err.status).json({ error: err.message, ...(err.payload ?? {}) });
102
+ return;
103
+ }
104
+ console.error('[plugins] rename failed:', err);
105
+ res.status(500).json({ error: 'Failed to rename the plugin' });
106
+ }
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Linking shared skills — see `PluginLinksService` for the two-sided write.
112
+ *
113
+ * POST /api/plugins/:name/links { skillPath } → { root, skills }
114
+ * DELETE /api/plugins/:name/links?skillPath= → { root, revoked }
115
+ * POST /api/plugins/:name/links/repair { skillPath } → { root }
116
+ *
117
+ * Refusals carry a `kind` the UI branches on — `needs-skill-write` (409) is
118
+ * the one that becomes "request write access".
119
+ */
120
+ if (links) {
121
+ const linkOp = async (
122
+ req: express.Request,
123
+ res: express.Response,
124
+ op: (user: AuthUser, plugin: string, skillPath: string) => Promise<unknown>,
125
+ skillPathOf: (req: express.Request) => unknown,
126
+ ) => {
127
+ if (!req.userEmail) {
128
+ res.status(401).json({ error: 'Unauthenticated' });
129
+ return;
130
+ }
131
+ const user = await resolveUser(req);
132
+ if (!user) {
133
+ res.status(401).json({ error: 'Unauthenticated' });
134
+ return;
135
+ }
136
+ const skillPath = skillPathOf(req);
137
+ if (typeof skillPath !== 'string' || !skillPath.trim()) {
138
+ res.status(400).json({ error: 'skillPath is required' });
139
+ return;
140
+ }
141
+ try {
142
+ res.json(await op(user, String(req.params.name), skillPath));
143
+ } catch (err) {
144
+ if (err instanceof PluginLinkError) {
145
+ res.status(err.status).json({ error: err.message, ...err.payload });
146
+ return;
147
+ }
148
+ if (err instanceof WorkflowDomainError) {
149
+ res.status(err.status).json({ error: err.message, ...(err.payload ?? {}) });
150
+ return;
151
+ }
152
+ console.error('[plugins] link operation failed:', err);
153
+ res.status(500).json({ error: 'Failed to update the plugin\'s links' });
154
+ }
155
+ };
156
+ const bodyPath = (req: express.Request) => ((req.body ?? {}) as { skillPath?: unknown }).skillPath;
157
+ router.post('/plugins/:name/links', (req, res) => linkOp(req, res, (u, p, s) => links.link(u, p, s), bodyPath));
158
+ router.post('/plugins/:name/links/repair', (req, res) =>
159
+ linkOp(req, res, (u, p, s) => links.repair(u, p, s), bodyPath),
160
+ );
161
+ router.delete('/plugins/:name/links', (req, res) =>
162
+ linkOp(req, res, (u, p, s) => links.unlink(u, p, s), (r) => r.query.skillPath),
163
+ );
164
+ }
165
+
67
166
  /** The folder-chain probe for MEMBERSHIP — the folder itself. */
68
167
  const memberProbe = (folder: string) => folder;
69
168
  /** The FILE probe for discovery/management — the folder's access.md. */
70
169
  const accessMdOf = (folder: string) => `${folder}/access.md`;
170
+ /** A plugin folder's path BELOW the plugins root: `GTM`, or `teams/deep`. */
171
+ const folderBelowRoot = (folder: string) => folder.slice(folder.indexOf('/') + 1);
172
+ /**
173
+ * What a join request is keyed by: the plugin's primary FOLDER path below
174
+ * the root, not its identity. The request writes into that folder's rules;
175
+ * a top-level folder keys exactly as every join branch already on a remote
176
+ * was cut (its name), a nested one by its whole path, so two plugins whose
177
+ * folders share a basename never share a branch; and a rename of the
178
+ * identity moves no folder, so it orphans no open request.
179
+ */
180
+ const joinKeyOf = (g: PluginCatalogEntry) => folderBelowRoot(g.folders[0]);
71
181
 
72
182
  const probesFor = (plugins: PluginCatalogEntry[]): string[] => [
73
183
  ...new Set(plugins.flatMap((g) => g.folders.flatMap((f) => [memberProbe(f), accessMdOf(f)]))),
@@ -122,15 +232,18 @@ export function createPluginsRoutes(
122
232
  const owner = any(owned, g, memberProbe);
123
233
  const discoverable = member || any(readable, g, accessMdOf);
124
234
  if (!member && !manager && !discoverable) continue; // absent — fail closed
125
- const joinCr = member ? null : openJoinCr(mine, email, g.name);
235
+ const joinCr = member ? null : openJoinCr(mine, email, joinKeyOf(g));
126
236
  plugins.push({
127
237
  name: g.name,
238
+ displayName: g.displayName,
128
239
  folders: g.folders,
129
240
  canRead: member,
130
241
  canWrite: manager,
131
242
  isOwner: owner,
243
+ linksAreManaged: g.linksAreManaged,
132
244
  skillCount: g.skillCount,
133
245
  toolCount: g.toolCount,
246
+ brokenLinks: g.brokenLinks,
134
247
  owners: g.owners,
135
248
  writers: g.writers,
136
249
  readers: g.readers,
@@ -230,7 +343,7 @@ export function createPluginsRoutes(
230
343
  res.status(401).json({ error: 'Unauthenticated' });
231
344
  return;
232
345
  }
233
- // Case-sensitive, like `pluginOfPath` — the plugin name IS the folder name.
346
+ // By identity — the manifest name the catalog keys on.
234
347
  const plugin = (await pluginIndex.catalog()).find((g) => g.name === req.params.name);
235
348
  const wsId = pluginsWorkspaceId();
236
349
  const ownerVerdicts = plugin
@@ -242,7 +355,10 @@ export function createPluginsRoutes(
242
355
  res.status(404).json({ error: 'Unknown plugin', kind: 'unknown-plugin' });
243
356
  return;
244
357
  }
245
- await provision.deletePlugin(user, plugin.name);
358
+ // Provisioning works on FOLDERS (it created one); the identity only
359
+ // found the plugin. The whole path below the root, so a nested plugin
360
+ // is deleted where it is.
361
+ await provision.deletePlugin(user, folderBelowRoot(plugin.folders[0]));
246
362
  pluginIndex.invalidate();
247
363
  res.json({ ok: true });
248
364
  } catch (err) {
@@ -268,7 +384,7 @@ export function createPluginsRoutes(
268
384
  return;
269
385
  }
270
386
  const catalog = await pluginIndex.catalog();
271
- // Case-sensitive, like `pluginOfPath` — the plugin name IS the folder name.
387
+ // By identity — the manifest name the catalog keys on.
272
388
  const plugin = catalog.find((g) => g.name === req.params.name);
273
389
  const wsId = pluginsWorkspaceId();
274
390
  // Same ANY-folder shape `GET /plugins` resolves with, so a plugin can
@@ -298,8 +414,8 @@ export function createPluginsRoutes(
298
414
  // expects.
299
415
  const folder = plugin.folders[0];
300
416
 
301
- const branch = joinBranchFor(email, plugin.name);
302
- const existing = openJoinCr(await workflow.listChangeRequestsAuthoredBy(email), email, plugin.name);
417
+ const branch = joinBranchFor(email, joinKeyOf(plugin));
418
+ const existing = openJoinCr(await workflow.listChangeRequestsAuthoredBy(email), email, joinKeyOf(plugin));
303
419
  if (existing) {
304
420
  res.json({ ok: true, number: existing.number });
305
421
  return;
@@ -323,14 +439,15 @@ export function createPluginsRoutes(
323
439
  );
324
440
  if (spliced.changed) {
325
441
  await workspaceService.writeFile(ws.id, accessPath, spliced.text);
326
- await workflow.commitChanges(ws.id, user, `Request access to ${plugin.name}`);
442
+ await workflow.commitChanges(ws.id, user, `Request access to ${plugin.displayName}`);
327
443
  }
328
444
  const detail = await workflow.openChangeRequest(ws.id, user, {
329
445
  sourceBranch: branch,
330
446
  targetBranch: DEFAULT_BRANCH,
331
- title: `Join request: ${plugin.name}`,
447
+ // People read these: the display name, not the identifier.
448
+ title: `Join request: ${plugin.displayName}`,
332
449
  description:
333
- `${user.name} asked to join ${plugin.name}. A manager of the plugin accepts by ` +
450
+ `${user.name} asked to join ${plugin.displayName}. A manager of the plugin accepts by ` +
334
451
  `granting the access this branch proposes; the request closes itself once ` +
335
452
  `every proposal has landed.`,
336
453
  });
@@ -392,7 +509,7 @@ export function createPluginsRoutes(
392
509
  if (!ctx) return;
393
510
  const crs = await workflow.listChangeRequests();
394
511
  res.json({
395
- requests: await joinRequests.list(ctx.plugin.name, ctx.folder, crs, ctx.user),
512
+ requests: await joinRequests.list(joinKeyOf(ctx.plugin), ctx.folder, crs, ctx.user),
396
513
  });
397
514
  } catch (err) {
398
515
  console.error('[plugins] failed to list join requests:', err);
@@ -422,7 +539,7 @@ export function createPluginsRoutes(
422
539
  res.status(404).json({ error: 'Not found' });
423
540
  return;
424
541
  }
425
- res.json({ closed: await joinRequests.reconcile(ctx.plugin.name, ctx.folder, cr, ctx.user) });
542
+ res.json({ closed: await joinRequests.reconcile(joinKeyOf(ctx.plugin), ctx.folder, cr, ctx.user) });
426
543
  } catch (err) {
427
544
  console.error('[plugins] failed to reconcile a join request:', err);
428
545
  res.status(500).json({ error: 'Failed to update the request' });
@@ -1,11 +1,6 @@
1
1
  import path from 'node:path';
2
- import fs from 'node:fs/promises';
3
- import type { Dirent } from 'node:fs';
4
2
  import {
5
3
  DEFAULT_BRANCH,
6
- PLUGINS_DIR,
7
- pluginOfPath,
8
- isPersonalPluginFolder,
9
4
  } from '@bevel-software/platform-shared';
10
5
  import type { WorkspaceService } from '../workspace/workspace.service.js';
11
6
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
@@ -14,6 +9,9 @@ import type { ISkillService } from '../skills/skills.contract.js';
14
9
  import type { IToolManualService } from '../tool-manuals/tool-manuals.contract.js';
15
10
  import { TtlCache } from '../../shared/ttl-cache.js';
16
11
  import type { PluginCatalogEntry, IPluginIndexService } from './plugins.contract.js';
12
+ import type { PluginLinkIndex } from './plugin-links.js';
13
+ import type { PluginSource } from './discovery/plugin-source.js';
14
+ import { KbPluginSource } from './discovery/kb-plugin-source.js';
17
15
 
18
16
  const CACHE_TTL_MS = 60_000;
19
17
 
@@ -52,6 +50,14 @@ export class PluginIndexService implements IPluginIndexService {
52
50
  private readonly toolManualService: IToolManualService,
53
51
  private readonly kbDirName: string,
54
52
  now: () => number = Date.now,
53
+ /**
54
+ * The link index, when the deployment has one: a plugin's skill count is
55
+ * then inline PLUS linked. Optional so hosts composing their own service
56
+ * set (and older tests) keep the inline-only count.
57
+ */
58
+ private readonly links?: PluginLinkIndex,
59
+ /** Where plugins come from — native manifests unless a dialect is configured. */
60
+ private readonly source: PluginSource = new KbPluginSource(),
55
61
  ) {
56
62
  this.cache = new TtlCache(CACHE_TTL_MS, now);
57
63
  }
@@ -63,6 +69,9 @@ export class PluginIndexService implements IPluginIndexService {
63
69
  async catalog(): Promise<PluginCatalogEntry[]> {
64
70
  const cached = this.cache.get();
65
71
  if (cached) return cached;
72
+ // See `TtlCache.begin`: taken before the read so an `invalidate()` that
73
+ // lands mid-build discards this result instead of being overwritten by it.
74
+ const token = this.cache.begin();
66
75
  const entries = await this.build();
67
76
  // A failed scan and a KB with genuinely no plugin folders both serve `[]`,
68
77
  // but only the second is a fact worth holding for the TTL. Caching the
@@ -72,7 +81,7 @@ export class PluginIndexService implements IPluginIndexService {
72
81
  // never arrive in that window. So a degraded read is served, not stored,
73
82
  // and the next caller retries.
74
83
  if (entries === null) return [];
75
- this.cache.set(entries);
84
+ this.cache.set(entries, token);
76
85
  return entries;
77
86
  }
78
87
 
@@ -94,12 +103,13 @@ export class PluginIndexService implements IPluginIndexService {
94
103
  const wsId = (await this.workspaceService.getOrCreateForBranch(DEFAULT_BRANCH)).id;
95
104
  const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
96
105
 
97
- const folders = await this.scanFolders(kbRoot);
98
- if (folders.size === 0) return [];
106
+ const scanned = await this.scanFolders(kbRoot);
107
+ if (scanned.size === 0) return [];
108
+ const folders = new Map([...scanned].map(([name, p]) => [name, p.folders]));
99
109
 
100
- const [skillCounts, toolCounts] = await Promise.all([
101
- this.countSkills(),
102
- this.countTools(),
110
+ const [{ skillCounts, brokenLinkCounts }, toolCounts] = await Promise.all([
111
+ this.countThroughLinks(folders),
112
+ this.countTools(folders),
103
113
  ]);
104
114
 
105
115
  const entries: PluginCatalogEntry[] = [];
@@ -113,9 +123,12 @@ export class PluginIndexService implements IPluginIndexService {
113
123
  ]);
114
124
  entries.push({
115
125
  name,
126
+ displayName: scanned.get(name)?.displayName ?? name,
116
127
  folders: pluginFolders,
128
+ linksAreManaged: scanned.get(name)?.linksAreManaged ?? false,
117
129
  skillCount: skillCounts.get(name) ?? 0,
118
130
  toolCount: toolCounts.get(name) ?? 0,
131
+ brokenLinks: brokenLinkCounts.get(name) ?? 0,
119
132
  owners,
120
133
  writers,
121
134
  readers,
@@ -130,59 +143,99 @@ export class PluginIndexService implements IPluginIndexService {
130
143
  }
131
144
  }
132
145
 
133
- /** name → repo-relative plugin folder (always a single-element list). */
134
- private async scanFolders(kbRoot: string): Promise<Map<string, string[]>> {
135
- const byName = new Map<string, string[]>();
136
- let children: Dirent[];
137
- try {
138
- children = await fs.readdir(path.join(kbRoot, PLUGINS_DIR), { withFileTypes: true });
139
- } catch {
140
- return byName; // a KB without a `Plugins/` root simply has no plugins
146
+ /**
147
+ * name → repo-relative plugin folder (always a single-element list), from
148
+ * the configured source. Personal folders live under Plugins/ but are not
149
+ * plugins: one exists per person, private by construction, and listing them
150
+ * would put a locked row per employee in everyone's index. `exists` is the
151
+ * source's existence rule (see `DiscoveredPlugin`) — for native plugins,
152
+ * the `access.md` the class doc describes.
153
+ */
154
+ private async scanFolders(
155
+ kbRoot: string,
156
+ ): Promise<Map<string, { folders: string[]; linksAreManaged: boolean; displayName: string }>> {
157
+ const byName = new Map<string, { folders: string[]; linksAreManaged: boolean; displayName: string }>();
158
+ const discovered = await this.source.discover(kbRoot);
159
+ for (const w of discovered.warnings) console.warn(`[plugins] ${w}`);
160
+ for (const plugin of discovered.plugins) {
161
+ if (plugin.personal || !plugin.exists) continue;
162
+ byName.set(plugin.name, {
163
+ folders: [plugin.folder],
164
+ linksAreManaged: plugin.linksAreManaged,
165
+ displayName: plugin.displayName,
166
+ });
141
167
  }
142
- const candidates = children.filter(
143
- (child) =>
144
- child.isDirectory() &&
145
- !child.name.startsWith('.') &&
146
- // Personal folders live under Plugins/ but are not plugins: one exists
147
- // per person, private by construction, and listing them would put a
148
- // locked row per employee in everyone's index.
149
- !isPersonalPluginFolder(child.name),
150
- );
151
- // A plugin exists exactly when its folder carries an `access.md` (see the
152
- // class doc) — stat that file, don't trust the directory.
153
- const verdicts = await Promise.all(
154
- candidates.map(async (child) => {
155
- try {
156
- return (await fs.stat(path.join(kbRoot, PLUGINS_DIR, child.name, 'access.md'))).isFile();
157
- } catch {
158
- return false;
159
- }
160
- }),
161
- );
162
- candidates.forEach((child, i) => {
163
- if (verdicts[i]) byName.set(child.name, [`${PLUGINS_DIR}/${child.name}`]);
164
- });
165
168
  return byName;
166
169
  }
167
170
 
168
- private async countSkills(): Promise<Map<string, number>> {
169
- // `undefined` is the documented GLOBAL, unfiltered mode — counts are a
170
- // property of the plugin, not of who is asking.
171
- return bucketByPlugin(await this.skillService.listSkills(undefined));
171
+ /**
172
+ * Two counts from ONE read of the link index — the membership is built
173
+ * once per cold catalog, not once per count: `TtlCache` has no
174
+ * single-flight, so two concurrent readers of an empty cache would each
175
+ * discover the tree and resolve every linked skill's access.
176
+ *
177
+ * - `skillCounts`: with links, a skill counts for EVERY plugin that holds
178
+ * it — inline in its folder, or linked from a manifest. Personal folders
179
+ * are already absent from the membership (they are places, not plugins).
180
+ * - `brokenLinkCounts`: how many linked skills each plugin's members
181
+ * cannot read — memberships reported as linked but not granted. From the
182
+ * UNFILTERED index, so the count reaches the plugin's managers even when
183
+ * the missing grant locks them out of the skill too.
184
+ *
185
+ * Without a link index: skills bucket by folder, and no link can be broken.
186
+ */
187
+ private async countThroughLinks(
188
+ folders: Map<string, string[]>,
189
+ ): Promise<{ skillCounts: Map<string, number>; brokenLinkCounts: Map<string, number> }> {
190
+ const skillCounts = new Map<string, number>();
191
+ const brokenLinkCounts = new Map<string, number>();
192
+ if (!this.links) {
193
+ // `undefined` is the documented GLOBAL, unfiltered mode — counts are a
194
+ // property of the plugin, not of who is asking.
195
+ return {
196
+ skillCounts: bucketByFolder(await this.skillService.listSkills(undefined), folders),
197
+ brokenLinkCounts,
198
+ };
199
+ }
200
+ for (const memberships of (await this.links.membership()).bySkill.values()) {
201
+ for (const m of memberships) {
202
+ skillCounts.set(m.name, (skillCounts.get(m.name) ?? 0) + 1);
203
+ if (m.linked && !m.granted) brokenLinkCounts.set(m.name, (brokenLinkCounts.get(m.name) ?? 0) + 1);
204
+ }
205
+ }
206
+ return { skillCounts, brokenLinkCounts };
172
207
  }
173
208
 
174
- private async countTools(): Promise<Map<string, number>> {
175
- return bucketByPlugin(await this.toolManualService.listAllSummaries());
209
+ /**
210
+ * Tools belong to the plugin whose FOLDER holds them — matched by path
211
+ * prefix against the discovered folders, not by `pluginOfPath`'s
212
+ * second-segment rule, so a dialect plugin nested three folders deep still
213
+ * counts the servers its bundle expands.
214
+ */
215
+ private async countTools(folders: Map<string, string[]>): Promise<Map<string, number>> {
216
+ const counts = new Map<string, number>();
217
+ const byFolder = [...folders.entries()].map(([name, [folder]]) => ({ name, prefix: `${folder}/` }));
218
+ for (const tool of await this.toolManualService.listAllSummaries()) {
219
+ const owner = byFolder.find((f) => tool.path.startsWith(f.prefix));
220
+ if (!owner) continue;
221
+ counts.set(owner.name, (counts.get(owner.name) ?? 0) + 1);
222
+ }
223
+ return counts;
176
224
  }
177
225
  }
178
226
 
179
- /** Count items per plugin folder name; ungrouped items (`null`) count nowhere. */
180
- function bucketByPlugin(items: { path: string }[]): Map<string, number> {
227
+ /**
228
+ * Count items per plugin by FOLDER PREFIX against the discovered folders —
229
+ * not by `pluginOfPath`'s second-segment rule, which would file a nested
230
+ * plugin's skills under its grouping folder. Items in no plugin count nowhere.
231
+ */
232
+ function bucketByFolder(items: { path: string }[], folders: Map<string, string[]>): Map<string, number> {
181
233
  const counts = new Map<string, number>();
234
+ const byFolder = [...folders.entries()].map(([name, [folder]]) => ({ name, prefix: `${folder}/` }));
182
235
  for (const item of items) {
183
- const plugin = pluginOfPath(item.path);
184
- if (plugin === null) continue;
185
- counts.set(plugin, (counts.get(plugin) ?? 0) + 1);
236
+ const owner = byFolder.find((f) => item.path.startsWith(f.prefix));
237
+ if (!owner) continue;
238
+ counts.set(owner.name, (counts.get(owner.name) ?? 0) + 1);
186
239
  }
187
240
  return counts;
188
241
  }
@@ -150,6 +150,63 @@ describe('DeploymentSettingsService — secrets', () => {
150
150
  });
151
151
  });
152
152
 
153
+ describe('DeploymentSettingsService — KB layout', () => {
154
+ it('resolves the three roots to their defaults when nothing names them', () => {
155
+ const { db } = makeDb();
156
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
157
+ expect(settings.resolveKbLayout()).toEqual({
158
+ knowledgeBaseDir: 'KnowledgeBase',
159
+ skillsDir: 'Skills',
160
+ pluginsDir: 'Plugins',
161
+ });
162
+ });
163
+
164
+ it('takes a renamed root from the environment first, then the store', async () => {
165
+ const { db } = makeDb();
166
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
167
+ await settings.save({ skillsDir: 'skills', pluginsDir: 'plugins' }, null);
168
+ expect(settings.resolveKbLayout()).toMatchObject({ skillsDir: 'skills', pluginsDir: 'plugins' });
169
+ process.env.KB_SKILLS_DIR = 'capabilities';
170
+ expect(settings.resolveKbLayout().skillsDir).toBe('capabilities');
171
+ });
172
+
173
+ /**
174
+ * The trio rule is judged on the layout the save WOULD produce: a plugins
175
+ * folder renamed to collide with the skills folder already in effect is a
176
+ * collision even though the batch names only one of them.
177
+ */
178
+ it('refuses two roots that share a name, case-insensitively', async () => {
179
+ const { db } = makeDb();
180
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
181
+ await expect(settings.save({ pluginsDir: 'skills' }, null)).rejects.toBeInstanceOf(
182
+ SettingsValidationError,
183
+ );
184
+ await expect(settings.save({ pluginsDir: 'SKILLS' }, null)).rejects.toBeInstanceOf(
185
+ SettingsValidationError,
186
+ );
187
+ await expect(
188
+ settings.save({ knowledgeBaseDir: 'Content', skillsDir: 'content' }, null),
189
+ ).rejects.toBeInstanceOf(SettingsValidationError);
190
+ });
191
+
192
+ it('refuses a root that is not a single plain folder name', async () => {
193
+ const { db } = makeDb();
194
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
195
+ for (const bad of ['a/b', '..', '.hidden', 'x\\y']) {
196
+ await expect(settings.save({ skillsDir: bad }, null)).rejects.toBeInstanceOf(
197
+ SettingsValidationError,
198
+ );
199
+ }
200
+ });
201
+
202
+ it('marks a layout change as restart-to-apply', async () => {
203
+ const { db } = makeDb();
204
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
205
+ const { restartRequired } = await settings.save({ pluginsDir: 'Bundles' }, null);
206
+ expect(restartRequired).toBe(true);
207
+ });
208
+ });
209
+
153
210
  describe('DeploymentSettingsService — validation', () => {
154
211
  it('rejects the whole batch when one field is wrong', async () => {
155
212
  const { db, rows } = makeDb();
@@ -0,0 +1,77 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { AddressInfo } from 'node:net';
3
+ import { randomBytes } from 'node:crypto';
4
+ import express from 'express';
5
+ import { afterEach, describe, expect, it } from 'vitest';
6
+ import { createSetupRoutes, type LastSyncStatus } from '../setup.routes.js';
7
+ import { DeploymentSettingsService } from '../deployment-settings.service.js';
8
+ import type { IAdminAccessService } from '../../admin/admin.interface.js';
9
+ import type { Database } from '../../database/connection.js';
10
+
11
+ /**
12
+ * The remote-sync facts on `GET /setup/status`: the address a hook calls and
13
+ * what the last sync did. Admin-only like the rest of the settings — a
14
+ * non-admin learns nothing about the deployment's endpoints.
15
+ */
16
+ const ENC_KEY = randomBytes(32).toString('base64');
17
+
18
+ let server: HttpServer | null = null;
19
+ afterEach(() => {
20
+ server?.close();
21
+ server = null;
22
+ });
23
+
24
+ function listen(isAdmin: boolean, last: LastSyncStatus | null) {
25
+ const db = {
26
+ select: () => ({ from: () => Promise.resolve([]) }),
27
+ insert: () => ({ values: () => ({ onConflictDoUpdate: () => Promise.resolve() }) }),
28
+ delete: () => ({ where: () => Promise.resolve() }),
29
+ } as unknown as Database;
30
+ const settings = new DeploymentSettingsService(db, ENC_KEY);
31
+ const app = express();
32
+ app.use(express.json());
33
+ app.use((req, _res, next) => {
34
+ req.userEmail = 'root@example.com';
35
+ req.userId = 'user-1';
36
+ next();
37
+ });
38
+ app.use(
39
+ '/api',
40
+ createSetupRoutes(
41
+ settings,
42
+ { isAdmin: async () => isAdmin } as IAdminAccessService,
43
+ { runAll: async () => {} },
44
+ { url: 'https://hexis.example.test/api/sync', lastSync: () => last },
45
+ ),
46
+ );
47
+ server = app.listen(0);
48
+ const { port } = server.address() as AddressInfo;
49
+ return `http://127.0.0.1:${port}`;
50
+ }
51
+
52
+ describe('GET /setup/status — remote sync', () => {
53
+ it('tells an admin the address and the last sync', async () => {
54
+ const last: LastSyncStatus = {
55
+ at: 1_700_000_000_000,
56
+ by: 'bearer',
57
+ status: 'synced',
58
+ results: [{ branch: 'main', outcome: 'updated' }],
59
+ };
60
+ const res = await fetch(`${listen(true, last)}/api/setup/status`);
61
+ const body = (await res.json()) as { sync?: unknown };
62
+ expect(body.sync).toEqual({ url: 'https://hexis.example.test/api/sync', last });
63
+ });
64
+
65
+ it('says null before the first sync', async () => {
66
+ const res = await fetch(`${listen(true, null)}/api/setup/status`);
67
+ const body = (await res.json()) as { sync?: { last: unknown } };
68
+ expect(body.sync?.last).toBeNull();
69
+ });
70
+
71
+ it('tells a non-admin nothing', async () => {
72
+ const res = await fetch(`${listen(false, null)}/api/setup/status`);
73
+ const body = (await res.json()) as Record<string, unknown>;
74
+ expect(body.sync).toBeUndefined();
75
+ expect(body.settings).toBeUndefined();
76
+ });
77
+ });