@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
@@ -31,8 +31,12 @@
31
31
  * personal skill.
32
32
  */
33
33
 
34
+ import { randomUUID } from 'node:crypto';
34
35
  import fs from 'node:fs/promises';
35
36
  import path from 'node:path';
37
+ import { isAbsence } from '../../shared/fs-errors.js';
38
+ import type { Discovery, PluginSource } from './discovery/plugin-source.js';
39
+ import { KbPluginSource } from './discovery/kb-plugin-source.js';
36
40
 
37
41
  import {
38
42
  DEFAULT_BRANCH,
@@ -41,8 +45,9 @@ import {
41
45
  pluginManifestName,
42
46
  renderPluginManifest,
43
47
  PERSONAL_PLUGIN_PREFIX,
44
- isPersonalPluginFolder,
48
+ isPersonalPluginDir,
45
49
  personalPluginFolderName,
50
+ validateFilename,
46
51
  type AuthUser,
47
52
  } from '@bevel-software/platform-shared';
48
53
  import type { WorkspaceService } from '../workspace/workspace.service.js';
@@ -62,9 +67,40 @@ export interface ProvisionCommitDriver {
62
67
  ): Promise<void>;
63
68
  }
64
69
 
70
+ /**
71
+ * A directory's entries, or null when there is no such directory. ONLY
72
+ * absence reads as "nothing there": a listing that fails for any other
73
+ * reason (permissions, I/O) throws, because "the folder is not there" and
74
+ * "the folder could not be read" must never collapse into one answer — the
75
+ * first is a 404 to a caller, the second an outage an operator must see.
76
+ */
77
+ async function listDirOrIncomplete(
78
+ dir: string,
79
+ repoRel: string,
80
+ ): Promise<Array<{ name: string; isDirectory(): boolean }> | null> {
81
+ try {
82
+ return await fs.readdir(dir, { withFileTypes: true });
83
+ } catch (err) {
84
+ if (isAbsence(err)) return null;
85
+ // The same refusal a hole in discovery gets: this listing is one more
86
+ // read the operation needed and could not have.
87
+ throw incompleteDiscovery([repoRel]);
88
+ }
89
+ }
90
+
91
+ /** The refusal for a discovery with a hole in it: a name cannot be checked, an identity cannot be known. */
92
+ function incompleteDiscovery(unreadable: string[]): PluginProvisionError {
93
+ return new PluginProvisionError(
94
+ `Some of the knowledge base could not be read (${unreadable.join(', ')}), so the plugin cannot be checked against every other. Try again, or ask an admin.`,
95
+ 503,
96
+ );
97
+ }
98
+
65
99
  export interface ProvisionedPlugin {
66
100
  /** The folder name under `Plugins/` (not the full path). */
67
101
  folder: string;
102
+ /** The plugin's identity — the manifest name written for it. */
103
+ name: string;
68
104
  /** False when an ensure found the folder already there. */
69
105
  created: boolean;
70
106
  }
@@ -97,6 +133,8 @@ export class PluginProvisionService {
97
133
  private readonly accessControl: IAccessControl,
98
134
  private readonly kbDirName: string,
99
135
  private readonly events?: { emit(event: { kind: 'fs-tree-changed'; workspaceId: string; branch: string }): void },
136
+ /** Which names are TAKEN is discovery's answer — the same one every catalog gets. */
137
+ private readonly source: PluginSource = new KbPluginSource(),
100
138
  ) {}
101
139
 
102
140
  /**
@@ -108,9 +146,9 @@ export class PluginProvisionService {
108
146
  async createPlugin(user: AuthUser, rawName: string): Promise<ProvisionedPlugin> {
109
147
  const name = rawName.trim();
110
148
  if (!name) throw new PluginProvisionError('A plugin needs a name.', 422);
111
- // eslint-disable-next-line no-control-regex -- NUL and control chars are
112
- // exactly what a filesystem path cannot carry; refusing them here keeps
113
- // the refusal a 422 instead of the fs layer's 500.
149
+ // NUL and control chars are exactly what a filesystem path cannot carry;
150
+ // refusing them here keeps the refusal a 422 instead of the fs layer's 500.
151
+ // eslint-disable-next-line no-control-regex
114
152
  if (/[/\\\u0000-\u001f\u007f]/.test(name) || name === '.' || name === '..' || name.startsWith('.')) {
115
153
  throw new PluginProvisionError(
116
154
  'A plugin name can\'t contain / or \\ or control characters, or start with a dot.',
@@ -156,7 +194,7 @@ export class PluginProvisionService {
156
194
  );
157
195
  }
158
196
  await this.provision(user, name, pluginAccessMd(user));
159
- return { folder: name, created: true };
197
+ return { folder: name, name: pluginManifestName(name), created: true };
160
198
  });
161
199
  }
162
200
 
@@ -168,7 +206,7 @@ export class PluginProvisionService {
168
206
  const folder = personalPluginFolderName(user.id);
169
207
  return this.creations.run(`plugin:${pluginManifestName(folder)}`, async () => {
170
208
  if ((await this.existingFolder(folder)) !== null) {
171
- return { folder, created: false };
209
+ return { folder, name: pluginManifestName(folder), created: false };
172
210
  }
173
211
  try {
174
212
  await this.provision(user, folder, personalAccessMd(user));
@@ -177,11 +215,11 @@ export class PluginProvisionService {
177
215
  // process, a checkout that appeared between check and write): the
178
216
  // folder existing is this method's success case, never its error.
179
217
  if (err instanceof PluginProvisionError && err.status === 409) {
180
- return { folder, created: false };
218
+ return { folder, name: pluginManifestName(folder), created: false };
181
219
  }
182
220
  throw err;
183
221
  }
184
- return { folder, created: true };
222
+ return { folder, name: pluginManifestName(folder), created: true };
185
223
  });
186
224
  }
187
225
 
@@ -204,29 +242,64 @@ export class PluginProvisionService {
204
242
  * never interleave with a re-creation of the same name (the key derives
205
243
  * from the name, so both spell it identically).
206
244
  */
207
- async deletePlugin(user: AuthUser, rawName: string): Promise<void> {
208
- const name = rawName.trim();
209
- if (!name) throw new PluginProvisionError('A plugin needs a name.', 422);
210
- if (isPersonalPluginFolder(name)) {
245
+ async deletePlugin(user: AuthUser, rawFolder: string): Promise<void> {
246
+ // The folder's path BELOW the plugins root — `GTM`, or `teams/deep` for a
247
+ // plugin nested where discovery found it. Segments only: nothing that
248
+ // could climb out of the root, and no backslash — `/` is the one
249
+ // separator the repository speaks, and a `\` would read as a second one
250
+ // on Windows and as a name character everywhere else.
251
+ // Validated AS GIVEN, never trimmed first: a folder spelled with a
252
+ // leading or trailing space names no folder the catalog handed out, and
253
+ // trimming it would delete a different one. (`validateFilename` refuses
254
+ // the whitespace.)
255
+ const name = rawFolder;
256
+ const segments = name.split('/');
257
+ // Every segment must be a name the filesystem carries (the ONE rule
258
+ // creation applies, `validateFilename`: no control characters, no `\`,
259
+ // no `.`/`..`, no reserved names) and none may be dot-prefixed (a parked
260
+ // delete, invisible to every scanner) — refused here as a 422, never
261
+ // discovered by the fs layer as a 500.
262
+ if (!name || segments.some((s) => validateFilename(s) !== null || s.startsWith('.'))) {
263
+ throw new PluginProvisionError('A plugin needs a name.', 422);
264
+ }
265
+ if (isPersonalPluginDir(`${PLUGINS_DIR}/${name}`)) {
211
266
  // Personal folders are not plugins (the catalog never lists them), and
212
267
  // nobody deletes somebody's private shelf through the plugin door.
213
268
  throw new PluginProvisionError('Unknown plugin', 404);
214
269
  }
215
- return this.creations.run(`plugin:${pluginManifestName(name)}`, async () => {
216
- const existing = await this.existingFolder(name);
217
- // Exact match only — the catalog hands the route the on-disk casing,
218
- // so a mismatch means the plugin is gone (or was never there).
219
- if (existing !== name) throw new PluginProvisionError('Unknown plugin', 404);
270
+ const wsId = await this.readyWorkspaceId();
271
+ const wsDir = await this.workspaceService.getWorkspacePath(wsId);
272
+ const pluginsDir = path.join(wsDir, this.kbDirName, PLUGINS_DIR);
273
+ const folderDir = path.join(pluginsDir, ...segments);
274
+ // Locked on the plugin's IDENTITY — the same key a creation of that name
275
+ // takes — not on a slug of the folder path, which for a nested plugin is a
276
+ // different string and would let a creation of the same identity run
277
+ // inside the delete's window. The identity is DISCOVERY's answer, in
278
+ // either file shape (a bundle's name as much as a manifest's), so delete
279
+ // and create can never key on two spellings of one plugin; a hole in
280
+ // discovery refuses, as it does for creation. A folder discovery does not
281
+ // list (no manifest at all) locks on its own slug — nothing else can
282
+ // claim that identity either.
283
+ const identity = await this.discoveredIdentity(`${PLUGINS_DIR}/${name}`, segments[segments.length - 1]!);
284
+ return this.creations.run(`plugin:${identity}`, async () => {
285
+ // Exact spelling of EVERY component — the catalog hands the route the
286
+ // on-disk spelling, so a mismatch means the plugin is gone (or was
287
+ // never there). Checked against directory listings, never `stat`: on a
288
+ // case-insensitive filesystem a stale spelling would stat a replacement
289
+ // plugin at the same location and park THAT.
290
+ if (!(await this.exactFolderExists(pluginsDir, segments))) {
291
+ throw new PluginProvisionError('Unknown plugin', 404);
292
+ }
220
293
 
221
- const wsId = await this.readyWorkspaceId();
222
- const wsDir = await this.workspaceService.getWorkspacePath(wsId);
223
- const pluginsDir = path.join(wsDir, this.kbDirName, PLUGINS_DIR);
224
- const folderDir = path.join(pluginsDir, name);
225
294
  // Dot-prefixed ⇒ invisible to the plugin scanner and the collision
226
- // check for the whole window the commit is in flight.
227
- const parkedDir = path.join(pluginsDir, `.${name}.deleting`);
228
-
229
- await fs.rm(parkedDir, { recursive: true, force: true }); // a stale park from a crashed run
295
+ // check for the whole window the commit is in flight. UNIQUE, so the
296
+ // park never lands on — and never removes — a path that was already
297
+ // there: a sibling somebody named that way, or the residue of a run
298
+ // that crashed mid-delete (which stays, invisible, for a person to
299
+ // clear; a delete must never destroy anything but the plugin it names).
300
+ // FIXED LENGTH: the plugin's own name is not part of it, so a name near
301
+ // the filesystem's component limit parks as well as a short one.
302
+ const parkedDir = path.join(path.dirname(folderDir), `.deleting-${randomUUID()}`);
230
303
  await fs.rename(folderDir, parkedDir);
231
304
  try {
232
305
  // Inline and `systemAuthorized`, for `provision`'s reasons in
@@ -261,45 +334,78 @@ export class PluginProvisionService {
261
334
  });
262
335
  }
263
336
 
337
+ /**
338
+ * Whether `segments` names a directory below `root` with every component
339
+ * spelled exactly as on disk — by listing each level, which is the one
340
+ * question a case-insensitive filesystem answers honestly.
341
+ */
342
+ private async exactFolderExists(root: string, segments: string[]): Promise<boolean> {
343
+ let dir = root;
344
+ let rel = PLUGINS_DIR;
345
+ for (const segment of segments) {
346
+ const entries = await listDirOrIncomplete(dir, rel);
347
+ if (!entries?.some((e) => e.isDirectory() && e.name === segment)) return false;
348
+ dir = path.join(dir, segment);
349
+ rel = `${rel}/${segment}`;
350
+ }
351
+ return true;
352
+ }
353
+
264
354
  /** The taken name (in its on-disk casing) colliding with `name`, or null. */
265
355
  private async existingFolder(name: string): Promise<string | null> {
266
356
  const wsId = await this.readyWorkspaceId();
267
357
  const wsDir = await this.workspaceService.getWorkspacePath(wsId);
268
- let children: string[];
269
- try {
270
- children = await fs.readdir(path.join(wsDir, this.kbDirName, PLUGINS_DIR));
271
- } catch {
272
- return null; // no Plugins/ root yet — nothing can collide
273
- }
358
+ // No Plugins/ root yet — nothing can collide.
359
+ const children = await listDirOrIncomplete(path.join(wsDir, this.kbDirName, PLUGINS_DIR), PLUGINS_DIR);
360
+ if (!children) return null;
274
361
  const lower = name.toLowerCase();
275
- return children.find((c) => c.toLowerCase() === lower) ?? null;
362
+ return children.find((c) => c.name.toLowerCase() === lower)?.name ?? null;
276
363
  }
277
364
 
278
- /** An existing PLUGIN FOLDER whose derived manifest name equals `name`'s, or null. */
279
- private async manifestNameTwin(name: string): Promise<string | null> {
365
+ /**
366
+ * The identity discovery gives the plugin at `folder`, or the folder's own
367
+ * slug when it lists none there. ONE rule for every write provisioning
368
+ * makes — create, delete — as for the rename: no write over a discovery
369
+ * with a hole in it, whether or not the hole is where this plugin lives.
370
+ * An operation that parks and commits against an identity set it could
371
+ * not fully see is the class of mistake the rule exists to make impossible.
372
+ */
373
+ private async discoveredIdentity(folder: string, leaf: string): Promise<string> {
374
+ const { plugins, unreadable } = await this.discovered();
375
+ if (unreadable.length > 0) throw incompleteDiscovery(unreadable);
376
+ const found = plugins.find((p) => p.folder === folder);
377
+ // The lock key is the SLUG — what the marketplace publishes and what a
378
+ // creation of the name locks on — so a bundle declaring "Sales Team"
379
+ // and a creation of `sales-team` take one lock. Not listed and nothing
380
+ // unreadable: no manifest, no bundle — the folder is its own identity.
381
+ return pluginManifestName(found ? found.name : leaf);
382
+ }
383
+
384
+ /** One discovery over the knowledge base checkout. */
385
+ private async discovered(): Promise<Discovery> {
280
386
  const wsId = await this.readyWorkspaceId();
281
387
  const wsDir = await this.workspaceService.getWorkspacePath(wsId);
282
- let children: Array<{ name: string; isDirectory(): boolean }>;
283
- try {
284
- children = await fs.readdir(path.join(wsDir, this.kbDirName, PLUGINS_DIR), {
285
- withFileTypes: true,
286
- });
287
- } catch {
288
- return null; // no Plugins/ root yet — nothing can collide
289
- }
388
+ return this.source.discover(path.join(wsDir, this.kbDirName));
389
+ }
390
+
391
+ /**
392
+ * An existing plugin — at ANY depth, in either file shape — whose identity
393
+ * folds to the same slug as `name`'s, as its repo-relative folder; or null.
394
+ * Discovery's answer, so creation and every catalog agree on what is taken:
395
+ * a nested plugin claims its identity as fully as a top-level one, and a
396
+ * parked delete (dot-prefixed, invisible to the walk) claims nothing.
397
+ * Personal folders count — they publish a plugin.json like any plugin
398
+ * (though the reservation above means a named plugin never reaches this
399
+ * check with a personal slug). A loose file at the root is not a plugin.
400
+ *
401
+ * Refuses on a HOLE: a listing that could not see part of the tree cannot
402
+ * prove a name free.
403
+ */
404
+ private async manifestNameTwin(name: string): Promise<string | null> {
405
+ const { plugins, unreadable } = await this.discovered();
406
+ if (unreadable.length > 0) throw incompleteDiscovery(unreadable);
290
407
  const slug = pluginManifestName(name);
291
- // Only what actually publishes a manifest claims a slug: a DIRECTORY
292
- // that is not dot-prefixed (a parked delete — invisible to every
293
- // scanner). Personal folders count — they publish a plugin.json like
294
- // any plugin (though the reservation above means a named plugin can
295
- // never reach this check with a personal slug). A loose file at the
296
- // root (`Plugins/slack.tool`) is not a plugin and must not 409 a
297
- // legitimate "Slack Tool".
298
- return (
299
- children.find(
300
- (c) => c.isDirectory() && !c.name.startsWith('.') && pluginManifestName(c.name) === slug,
301
- )?.name ?? null
302
- );
408
+ return plugins.find((p) => pluginManifestName(p.name) === slug)?.folder ?? null;
303
409
  }
304
410
 
305
411
  private async provision(user: AuthUser, folder: string, accessMd: string): Promise<void> {
@@ -0,0 +1,298 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ PERSONAL_PLUGIN_PREFIX,
5
+ PLUGIN_MANIFEST_FILE,
6
+ isPluginIdentifier,
7
+ pluginDisplayNameOf,
8
+ pluginManifestName,
9
+ } from '@bevel-software/platform-shared';
10
+ import type { AuthUser } from '@bevel-software/platform-shared';
11
+ import { PushNeedsAgentResolutionError } from '../../shared/domain-errors.js';
12
+ import { walkKb, type KbWalkListener } from '../../shared/kb-walk.js';
13
+ import type { IAccessControl } from '../access/access-control.interface.js';
14
+ import {
15
+ PLUGIN_TOKEN_PREFIX,
16
+ canonicalPluginToken,
17
+ hasAccessFrontmatterExtension,
18
+ parsePluginPrincipalKey,
19
+ } from '../access-model/access-grammar.js';
20
+ import type { WorkspaceService } from '../workspace/workspace.service.js';
21
+ import type { PluginSource } from './discovery/plugin-source.js';
22
+ import { linksWorkspaceId } from './plugin-links.js';
23
+
24
+ export class PluginRenameError extends Error {
25
+ constructor(
26
+ message: string,
27
+ readonly status: number,
28
+ readonly payload: Record<string, unknown> = {},
29
+ ) {
30
+ super(message);
31
+ this.name = 'PluginRenameError';
32
+ }
33
+ }
34
+
35
+ /** The one commit the rename lands as — a scoped batch, pushed. */
36
+ export interface RenameCommitDriver {
37
+ commitChanges(workspaceId: string, user: AuthUser, summary: string, onlyPaths: string[]): Promise<unknown>;
38
+ }
39
+
40
+ export interface RenameResult {
41
+ name: string;
42
+ displayName: string;
43
+ /** Repo-relative files whose grants were rewritten, for the response and the log. */
44
+ rewritten: string[];
45
+ }
46
+
47
+ /**
48
+ * Renaming a plugin — its identifier, its display name, or both.
49
+ *
50
+ * The identifier (the manifest's `name`) IS the plugin: the marketplace
51
+ * publishes it, the URLs key on it, and every grant of `plugin/<name>/<verb>`
52
+ * spells it. Changing it is therefore a rename of a PRINCIPAL, and a
53
+ * principal that is renamed in one file and not another is two principals,
54
+ * one of which nobody is a member of. So the rename is one commit: the
55
+ * manifest, plus every access entry in the knowledge base that names the old
56
+ * identifier, rewritten to the new one. Membership does not change — the
57
+ * same people hold the same verbs through the new spelling — but the caller
58
+ * still needs write on each file the commit touches: the protected-branch
59
+ * gate checks every path, and a rename that lands in some files and is
60
+ * refused in others is the split it exists to prevent. The refusal names the
61
+ * files, so the manager can ask their editors or an admin.
62
+ *
63
+ * The display name is free text with no consequences: the manifest changes,
64
+ * nothing else does.
65
+ */
66
+ export class PluginRenameService {
67
+ constructor(
68
+ private readonly workspaceService: WorkspaceService,
69
+ private readonly commits: RenameCommitDriver,
70
+ private readonly accessControl: IAccessControl,
71
+ private readonly source: PluginSource,
72
+ private readonly kbDirName: string,
73
+ private readonly events?: {
74
+ emit(event: { kind: 'fs-tree-changed'; workspaceId: string; branch: string }): void;
75
+ },
76
+ private readonly onChanged?: () => void,
77
+ ) {}
78
+
79
+ private noteChanged(wsId: string): void {
80
+ noteChangedFor(this.accessControl, this.events, this.onChanged, wsId);
81
+ }
82
+
83
+ /** The READ phase must have seen everything it needed; a hole refuses the rename before any write. */
84
+ private assertComplete(unreadable: string[]): void {
85
+ if (unreadable.length === 0) return;
86
+ throw new PluginRenameError(
87
+ `Some of the knowledge base could not be read (${unreadable.join(', ')}), so the rename cannot be checked against every plugin and grant. Try again, or ask an admin.`,
88
+ 503,
89
+ { kind: 'incomplete-discovery', unreadable },
90
+ );
91
+ }
92
+
93
+ async rename(
94
+ user: AuthUser,
95
+ current: string,
96
+ patch: { name?: unknown; displayName?: unknown },
97
+ ): Promise<RenameResult> {
98
+ const wsId = linksWorkspaceId();
99
+ const kbRoot = path.join(await this.workspaceService.getWorkspacePath(wsId), this.kbDirName);
100
+ // ONE walk of the checkout for everything the rename needs to know:
101
+ // the plugins (discovery's listener) and every file that can carry a
102
+ // grant (this one's), seeing one set of holes. Listing is all that
103
+ // happens here — no file is read, nothing is decided — so it can run
104
+ // before authorization without telling anyone anything.
105
+ const grantFiles: string[] = [];
106
+ const grantListener: KbWalkListener = {
107
+ onFile(dir, name) {
108
+ if (hasAccessFrontmatterExtension(name)) grantFiles.push(dir ? `${dir}/${name}` : name);
109
+ },
110
+ };
111
+ const { discovery, holes } = this.source.walkWith
112
+ ? await this.source.walkWith(kbRoot, [grantListener])
113
+ : { discovery: await this.source.discover(kbRoot), holes: (await walkKb(kbRoot, [grantListener])).holes };
114
+ const { plugins } = discovery;
115
+ const unreadable = [...new Set([...discovery.unreadable, ...holes])];
116
+ // Three phases: READ everything, DECIDE, then WRITE. Nothing about the
117
+ // tree — not even that part of it could not be read — reaches a caller
118
+ // before they are known to manage the plugin they name.
119
+ const plugin = plugins.find((p) => p.name === current.trim() && !p.personal && p.exists);
120
+ // Fail closed, like every plugin surface: unknown and not-yours answer alike.
121
+ if (!plugin || !(await this.accessControl.canWrite(wsId, user.email, `${plugin.folder}/access.md`))) {
122
+ throw new PluginRenameError('Unknown plugin', 404, { kind: 'unknown-plugin' });
123
+ }
124
+ if (!plugin.linksAreManaged) {
125
+ throw new PluginRenameError(
126
+ `${plugin.name} is read from an external plugin format; rename it in that repository.`,
127
+ 409,
128
+ { kind: 'read-only' },
129
+ );
130
+ }
131
+ const nextName = patch.name === undefined ? plugin.name : String(patch.name).trim();
132
+ // The rules govern a NEW identifier only. The current one is whatever the
133
+ // manifest says — a hand-written name that predates a rule (a reserved
134
+ // prefix, say) must not block a display-name change, and cannot be
135
+ // "fixed" by a rename that keeps it.
136
+ const identifierChanges = nextName !== plugin.name;
137
+ // A NEW identifier is claimed against EVERY plugin there is and rewritten
138
+ // into every grant there is. A listing with a hole in it — a folder or
139
+ // manifest that exists but could not be read — is neither set: the name
140
+ // could belong to what was not seen, the grant could live there. Fail
141
+ // closed; nothing is written. A display-name change claims nothing and
142
+ // touches one manifest, so it needs no more than its own plugin read.
143
+ if (identifierChanges) this.assertComplete(unreadable);
144
+ // An identifier must be its own slug: the marketplace and the collision
145
+ // checks key on `pluginManifestName(name)`, so a name that does not
146
+ // round-trip (longer than the slug's 64 characters, say) would be one
147
+ // thing here and another everywhere it is published.
148
+ if (identifierChanges && (!isPluginIdentifier(nextName) || pluginManifestName(nextName) !== nextName)) {
149
+ throw new PluginRenameError(
150
+ 'A plugin identifier is lowercase kebab-case: letters, digits and single hyphens, like "sales-team".',
151
+ 422,
152
+ { kind: 'bad-name' },
153
+ );
154
+ }
155
+ if (identifierChanges && nextName.startsWith(PERSONAL_PLUGIN_PREFIX)) {
156
+ throw new PluginRenameError(
157
+ `The "${PERSONAL_PLUGIN_PREFIX}" namespace is reserved for personal folders. Pick another identifier.`,
158
+ 422,
159
+ { kind: 'bad-name' },
160
+ );
161
+ }
162
+ // Taken by SLUG, not by spelling: a bundle may declare a name that is not
163
+ // an identifier, and the marketplace folds every name to its slug — a
164
+ // collision there drops one of the two from the catalog.
165
+ if (identifierChanges && plugins.some((p) => p !== plugin && pluginManifestName(p.name) === nextName)) {
166
+ throw new PluginRenameError(`A plugin named "${nextName}" already exists.`, 409, { kind: 'name-taken' });
167
+ }
168
+ const folderName = path.posix.basename(plugin.folder);
169
+ const nextDisplay =
170
+ patch.displayName === undefined ? plugin.displayName : String(patch.displayName).trim();
171
+
172
+ // The manifest, with everything else in it kept.
173
+ const manifest: Record<string, unknown> = { ...(plugin.manifest ?? {}), name: nextName };
174
+ if (nextDisplay && nextDisplay !== folderName) manifest.displayName = nextDisplay;
175
+ else delete manifest.displayName;
176
+ const manifestRel = `${this.kbDirName}/${plugin.folder}/${PLUGIN_MANIFEST_FILE}`;
177
+ const writes: { rel: string; text: string; before: string | null }[] = [
178
+ { rel: manifestRel, text: `${JSON.stringify(manifest, null, 2)}\n`, before: plugin.manifestText },
179
+ ];
180
+
181
+ if (identifierChanges) {
182
+ // Every access entry in the knowledge base that names the old
183
+ // principal — in a folder's access.md or in the own frontmatter of any
184
+ // file kind the resolver reads grants from (`.md`, `.tool`, whatever
185
+ // an overlay registered). The files came from the walk above, whose
186
+ // holes have already refused: a folder that could not be listed would
187
+ // leave the old spelling live in it, and a principal renamed in some
188
+ // files and not others is two principals. A file the walk listed but
189
+ // that cannot be opened is the same hole: every such file is
190
+ // collected, then refused together.
191
+ const unopened: string[] = [];
192
+ for (const rel of grantFiles) {
193
+ const abs = path.join(kbRoot, rel);
194
+ let before: string;
195
+ try {
196
+ before = await fs.readFile(abs, 'utf-8');
197
+ } catch {
198
+ unopened.push(rel);
199
+ continue;
200
+ }
201
+ const after = renamePluginPrincipalInText(before, plugin.name, nextName);
202
+ if (after !== before) writes.push({ rel: `${this.kbDirName}/${rel}`, text: after, before });
203
+ }
204
+ this.assertComplete(unopened);
205
+ const denied: string[] = [];
206
+ for (const w of writes.slice(1)) {
207
+ const repoRel = w.rel.slice(this.kbDirName.length + 1);
208
+ if (!(await this.accessControl.canWrite(wsId, user.email, repoRel))) denied.push(repoRel);
209
+ }
210
+ if (denied.length > 0) {
211
+ throw new PluginRenameError(
212
+ `Renaming ${plugin.name} would rewrite grants in files you can't edit: ${denied.join(', ')}. Ask their editors, or an admin.`,
213
+ 409,
214
+ { kind: 'needs-write', files: denied },
215
+ );
216
+ }
217
+ }
218
+
219
+ // Writes and commit share ONE failure envelope: whichever of them fails,
220
+ // the working tree goes back to what origin has — a half-written batch
221
+ // is as much a split principal as a refused commit.
222
+ try {
223
+ for (const w of writes) await this.workspaceService.writeFile(wsId, w.rel, w.text);
224
+ const summary = identifierChanges
225
+ ? `Rename plugin ${plugin.name} to ${nextName}`
226
+ : `Rename plugin ${plugin.name}: display name`;
227
+ await this.commits.commitChanges(
228
+ wsId,
229
+ user,
230
+ summary,
231
+ writes.map((w) => w.rel),
232
+ );
233
+ } catch (err) {
234
+ // One failure is NOT a failed commit: the commit landed locally and only
235
+ // the push did not (the typed hand-off the workflow throws). Restoring
236
+ // the old bytes then would stack an uncommitted inverse of a real
237
+ // commit; the recovery flow owns that state, so it is left alone — and
238
+ // since the tree DID change, every cache keyed on the old identity is
239
+ // dropped before the error goes up, exactly as on success.
240
+ if (err instanceof PushNeedsAgentResolutionError) {
241
+ this.noteChanged(wsId);
242
+ } else {
243
+ for (const w of writes) {
244
+ if (w.before !== null) await this.workspaceService.writeFile(wsId, w.rel, w.before).catch(() => undefined);
245
+ }
246
+ }
247
+ throw err;
248
+ }
249
+ this.noteChanged(wsId);
250
+ return {
251
+ name: nextName,
252
+ displayName: pluginDisplayNameOf(manifest, folderName),
253
+ rewritten: writes.slice(1).map((w) => w.rel.slice(this.kbDirName.length + 1)),
254
+ };
255
+ }
256
+ }
257
+
258
+ /**
259
+ * The tree changed: drop the resolver's model and the plugin caches, and
260
+ * tell every session. The ONE place this happens, for the landed commit and
261
+ * for the committed-but-unpushed one alike.
262
+ */
263
+ function noteChangedFor(
264
+ accessControl: IAccessControl,
265
+ events: PluginRenameService['events'],
266
+ onChanged: (() => void) | undefined,
267
+ wsId: string,
268
+ ): void {
269
+ accessControl.invalidate(wsId);
270
+ events?.emit({ kind: 'fs-tree-changed', workspaceId: wsId, branch: linksWorkspaceIdBranch() });
271
+ onChanged?.();
272
+ }
273
+
274
+ /**
275
+ * Every access entry line naming `plugin/<old>/<verb>` — in ANY spelling the
276
+ * parser accepts — rewritten to `plugin/<next>/<verb>`, the rest of the
277
+ * line (indent, `- `, a `deny` prefix, the line ending) kept. Lines that are
278
+ * not such an entry are untouched, so the file's own formatting survives.
279
+ */
280
+ export function renamePluginPrincipalInText(text: string, oldName: string, nextName: string): string {
281
+ const oldSlug = pluginManifestName(oldName);
282
+ return text
283
+ .split('\n')
284
+ .map((line) => {
285
+ const m = /^(\s*-\s+(?:deny\s+)?)(\S.*?)(\s*)$/i.exec(line);
286
+ if (!m) return line;
287
+ const key = canonicalPluginToken(m[2]!);
288
+ const parsed = key === null ? null : parsePluginPrincipalKey(key);
289
+ if (!parsed || parsed.slug !== oldSlug) return line;
290
+ return `${m[1]}${PLUGIN_TOKEN_PREFIX}${nextName}/${parsed.verb}${m[3]}`;
291
+ })
292
+ .join('\n');
293
+ }
294
+
295
+ function linksWorkspaceIdBranch(): string {
296
+ // The default branch's workspace id IS its encoded branch name.
297
+ return decodeURIComponent(linksWorkspaceId());
298
+ }
@@ -33,6 +33,8 @@
33
33
  * change requests; only the UI dresses them up.
34
34
  */
35
35
 
36
+ export type { PluginMembership } from '../skills/skills.contract.js';
37
+
36
38
  /** Access-rule principals as the resolver hands them back. */
37
39
  export interface ResolvedPrincipals {
38
40
  roles: string[];
@@ -45,10 +47,14 @@ export interface ResolvedReaders extends ResolvedPrincipals {
45
47
 
46
48
  /** One plugin as `GET /api/plugins` reports it, resolved for ONE caller. */
47
49
  export interface PluginSummary {
48
- /** Plugin folder name, e.g. `GTM`. */
50
+ /** The plugin's identity — its manifest name, e.g. `gtm`. */
49
51
  name: string;
52
+ /** What a person sees it called, e.g. `GTM`. */
53
+ displayName: string;
50
54
  /** Repo-relative constituent folders, e.g. `['Plugins/GTM']`. */
51
55
  folders: string[];
56
+ /** Whether this platform writes the plugin's links — see `PluginCatalogEntry`. */
57
+ linksAreManaged: boolean;
52
58
  /**
53
59
  * Per-caller: the caller can read the FOLDER (membership). Every returned
54
60
  * plugin has at least one of `canRead` / `canWrite` / discoverability; a
@@ -71,6 +77,16 @@ export interface PluginSummary {
71
77
  /** Caller-INDEPENDENT total (the plugin's whole content, not the caller's slice). */
72
78
  skillCount: number;
73
79
  toolCount: number;
80
+ /**
81
+ * Caller-INDEPENDENT: how many of the plugin's LINKED skills its members
82
+ * cannot read — the link is in the manifest, but the skill folder no
83
+ * longer grants the plugin's readers. Counted here, from the unfiltered
84
+ * link index, because the people who most need the warning are the
85
+ * plugin's managers, and they are exactly the people a missing grant
86
+ * locks out: a count derived from what the caller can read would be zero
87
+ * for them. Zero for a plugin whose links are not managed here.
88
+ */
89
+ brokenLinks: number;
74
90
  /** For display: "Run by …" (fallback chain: owners → writers → 'the workspace admins'). */
75
91
  owners: ResolvedPrincipals;
76
92
  writers: ResolvedPrincipals;
@@ -90,10 +106,21 @@ export interface PluginSummary {
90
106
  * appears at all) are resolved per request in the route.
91
107
  */
92
108
  export interface PluginCatalogEntry {
109
+ /** The plugin's identity — its manifest name. */
93
110
  name: string;
111
+ /** What a person sees it called — the manifest's `displayName`, else the folder name. */
112
+ displayName: string;
94
113
  folders: string[];
114
+ /**
115
+ * Whether this platform writes the plugin's links (a native `plugin.json`)
116
+ * — false for a plugin read from an external format, whose links are
117
+ * edited in that repository and which the link endpoints refuse.
118
+ */
119
+ linksAreManaged: boolean;
95
120
  skillCount: number;
96
121
  toolCount: number;
122
+ /** Linked skills the members cannot read — see `PluginSummary.brokenLinks`. */
123
+ brokenLinks: number;
97
124
  owners: ResolvedPrincipals;
98
125
  writers: ResolvedPrincipals;
99
126
  readers: ResolvedReaders;