@bevel-software/platform-core-backend 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/dist/core/core-ports.d.ts +15 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +40 -6
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +17 -1
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +153 -29
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core-config.d.ts +70 -21
  12. package/dist/core-config.d.ts.map +1 -1
  13. package/dist/core-config.js +134 -58
  14. package/dist/core-config.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +6 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +74 -5
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +116 -15
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access-declarations.d.ts +93 -0
  22. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  23. package/dist/modules/access/access-declarations.js +168 -0
  24. package/dist/modules/access/access-declarations.js.map +1 -0
  25. package/dist/modules/access/access-mutation.service.js +4 -4
  26. package/dist/modules/access/access-mutation.service.js.map +1 -1
  27. package/dist/modules/access/access-splice.d.ts +16 -1
  28. package/dist/modules/access/access-splice.d.ts.map +1 -1
  29. package/dist/modules/access/access-splice.js +40 -21
  30. package/dist/modules/access/access-splice.js.map +1 -1
  31. package/dist/modules/access/access.routes.d.ts.map +1 -1
  32. package/dist/modules/access/access.routes.js +51 -0
  33. package/dist/modules/access/access.routes.js.map +1 -1
  34. package/dist/modules/access/creator-access.d.ts +8 -0
  35. package/dist/modules/access/creator-access.d.ts.map +1 -1
  36. package/dist/modules/access/creator-access.js +21 -2
  37. package/dist/modules/access/creator-access.js.map +1 -1
  38. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  40. package/dist/modules/admin/admin-access.service.js +3 -2
  41. package/dist/modules/admin/admin-access.service.js.map +1 -1
  42. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.routes.js +50 -1
  44. package/dist/modules/auth/auth.routes.js.map +1 -1
  45. package/dist/modules/auth/auth.service.d.ts +39 -10
  46. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  47. package/dist/modules/auth/auth.service.js +38 -16
  48. package/dist/modules/auth/auth.service.js.map +1 -1
  49. package/dist/modules/database/core-schema.d.ts +124 -0
  50. package/dist/modules/database/core-schema.d.ts.map +1 -1
  51. package/dist/modules/database/core-schema.js +45 -0
  52. package/dist/modules/database/core-schema.js.map +1 -1
  53. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  54. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  55. package/dist/modules/groups/group-provision.service.js +288 -0
  56. package/dist/modules/groups/group-provision.service.js.map +1 -0
  57. package/dist/modules/groups/groups.contract.d.ts +106 -0
  58. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  59. package/dist/modules/groups/groups.contract.js +36 -0
  60. package/dist/modules/groups/groups.contract.js.map +1 -0
  61. package/dist/modules/groups/groups.routes.d.ts +42 -0
  62. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  63. package/dist/modules/groups/groups.routes.js +379 -0
  64. package/dist/modules/groups/groups.routes.js.map +1 -0
  65. package/dist/modules/groups/groups.service.d.ts +60 -0
  66. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  67. package/dist/modules/groups/groups.service.js +172 -0
  68. package/dist/modules/groups/groups.service.js.map +1 -0
  69. package/dist/modules/groups/index.d.ts +7 -0
  70. package/dist/modules/groups/index.d.ts.map +1 -0
  71. package/dist/modules/groups/index.js +6 -0
  72. package/dist/modules/groups/index.js.map +1 -0
  73. package/dist/modules/groups/join-proposals.d.ts +53 -0
  74. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  75. package/dist/modules/groups/join-proposals.js +67 -0
  76. package/dist/modules/groups/join-proposals.js.map +1 -0
  77. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  78. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  79. package/dist/modules/groups/join-requests.service.js +135 -0
  80. package/dist/modules/groups/join-requests.service.js.map +1 -0
  81. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  83. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  85. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  86. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  87. package/dist/modules/settings/deployment-settings.service.js +368 -0
  88. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  89. package/dist/modules/settings/setup.routes.d.ts +49 -0
  90. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  91. package/dist/modules/settings/setup.routes.js +292 -0
  92. package/dist/modules/settings/setup.routes.js.map +1 -0
  93. package/dist/modules/skills/index.d.ts +2 -1
  94. package/dist/modules/skills/index.d.ts.map +1 -1
  95. package/dist/modules/skills/index.js +1 -0
  96. package/dist/modules/skills/index.js.map +1 -1
  97. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  98. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  99. package/dist/modules/skills/pending-skills.service.js +147 -0
  100. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  101. package/dist/modules/skills/skills.contract.d.ts +32 -3
  102. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  103. package/dist/modules/skills/skills.contract.js +1 -1
  104. package/dist/modules/skills/skills.routes.d.ts +3 -2
  105. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  106. package/dist/modules/skills/skills.routes.js +18 -1
  107. package/dist/modules/skills/skills.routes.js.map +1 -1
  108. package/dist/modules/skills/skills.service.d.ts +22 -0
  109. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  110. package/dist/modules/skills/skills.service.js +24 -12
  111. package/dist/modules/skills/skills.service.js.map +1 -1
  112. package/dist/modules/tool-manuals/index.d.ts +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  114. package/dist/modules/tool-manuals/index.js.map +1 -1
  115. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  117. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  120. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  122. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  125. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  126. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  129. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  132. package/dist/modules/workflow/event-bus.d.ts +11 -0
  133. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  134. package/dist/modules/workflow/event-bus.js +22 -0
  135. package/dist/modules/workflow/event-bus.js.map +1 -1
  136. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  137. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  138. package/dist/modules/workflow/file-lock.service.js +16 -0
  139. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  140. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  142. package/dist/modules/workflow/git/branch-name.js +1 -1
  143. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  144. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  145. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  146. package/dist/modules/workflow/git/git.service.js +144 -52
  147. package/dist/modules/workflow/git/git.service.js.map +1 -1
  148. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  149. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  150. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  151. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  152. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  155. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.js +95 -14
  157. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  158. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  159. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  160. package/dist/modules/workflow/workflow.service.js +381 -108
  161. package/dist/modules/workflow/workflow.service.js.map +1 -1
  162. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  163. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  164. package/dist/modules/workspace/bevel-ignore.js +5 -1
  165. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  166. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  167. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  168. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  169. package/dist/modules/workspace/kb-seed.service.js +228 -51
  170. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  171. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  173. package/dist/modules/workspace/workspace.routes.js +51 -18
  174. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  175. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  176. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  177. package/dist/modules/workspace/workspace.service.js +36 -9
  178. package/dist/modules/workspace/workspace.service.js.map +1 -1
  179. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.js +8 -3
  181. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  182. package/dist/shared/kb-layout.test.js +2 -2
  183. package/dist/test-setup.d.ts +2 -0
  184. package/dist/test-setup.d.ts.map +1 -0
  185. package/dist/test-setup.js +18 -0
  186. package/dist/test-setup.js.map +1 -0
  187. package/kb-template/.bevelignore +6 -2
  188. package/kb-template/AGENTS.md +272 -0
  189. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  190. package/kb-template/access.md +36 -36
  191. package/migrations/0002_user_onboarding_done.sql +1 -0
  192. package/migrations/0003_deployment_settings.sql +9 -0
  193. package/migrations/meta/0001_snapshot.json +1486 -0
  194. package/migrations/meta/0002_snapshot.json +1493 -0
  195. package/migrations/meta/0003_snapshot.json +1552 -0
  196. package/migrations/meta/_journal.json +15 -1
  197. package/package.json +3 -3
  198. package/src/__tests__/branch-model.test.ts +79 -0
  199. package/src/__tests__/core-config.admin.test.ts +114 -0
  200. package/src/__tests__/database-url.test.ts +70 -0
  201. package/src/core/core-ports.ts +15 -0
  202. package/src/core/create-core-server.ts +55 -6
  203. package/src/core/create-core-services.ts +201 -36
  204. package/src/core-config.ts +139 -59
  205. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  206. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  207. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  208. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  209. package/src/modules/access/access-control.interface.ts +11 -0
  210. package/src/modules/access/access-control.service.ts +113 -15
  211. package/src/modules/access/access-declarations.ts +251 -0
  212. package/src/modules/access/access-mutation.service.ts +4 -4
  213. package/src/modules/access/access-splice.ts +64 -21
  214. package/src/modules/access/access.routes.ts +61 -0
  215. package/src/modules/access/creator-access.ts +22 -2
  216. package/src/modules/admin/admin-access.service.ts +4 -3
  217. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  218. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  219. package/src/modules/auth/auth.routes.ts +50 -1
  220. package/src/modules/auth/auth.service.ts +55 -17
  221. package/src/modules/database/core-schema.ts +46 -0
  222. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  223. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  224. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  225. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  226. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  227. package/src/modules/groups/group-provision.service.ts +328 -0
  228. package/src/modules/groups/groups.contract.ts +107 -0
  229. package/src/modules/groups/groups.routes.ts +433 -0
  230. package/src/modules/groups/groups.service.ts +193 -0
  231. package/src/modules/groups/index.ts +17 -0
  232. package/src/modules/groups/join-proposals.ts +114 -0
  233. package/src/modules/groups/join-requests.service.ts +185 -0
  234. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  235. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  236. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  237. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  238. package/src/modules/settings/deployment-settings.service.ts +419 -0
  239. package/src/modules/settings/setup.routes.ts +317 -0
  240. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  241. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  242. package/src/modules/skills/index.ts +3 -0
  243. package/src/modules/skills/pending-skills.service.ts +168 -0
  244. package/src/modules/skills/skills.contract.ts +34 -3
  245. package/src/modules/skills/skills.routes.ts +23 -2
  246. package/src/modules/skills/skills.service.ts +24 -16
  247. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  251. package/src/modules/tool-manuals/index.ts +2 -0
  252. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  253. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  254. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  255. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  256. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  257. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
  258. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  259. package/src/modules/workflow/event-bus.ts +25 -0
  260. package/src/modules/workflow/file-lock.service.ts +17 -0
  261. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  262. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  263. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  264. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  265. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  266. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  267. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
  268. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  269. package/src/modules/workflow/git/branch-name.ts +5 -1
  270. package/src/modules/workflow/git/git.service.ts +159 -62
  271. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  272. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  273. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  274. package/src/modules/workflow/workflow.routes.ts +91 -13
  275. package/src/modules/workflow/workflow.service.ts +353 -13
  276. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  277. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  278. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  279. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  280. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  281. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  282. package/src/modules/workspace/bevel-ignore.ts +66 -62
  283. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  284. package/src/modules/workspace/kb-seed.service.ts +247 -53
  285. package/src/modules/workspace/workspace.routes.ts +54 -18
  286. package/src/modules/workspace/workspace.service.ts +45 -7
  287. package/src/modules/workspace/workspace.tools.ts +922 -917
  288. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  289. package/src/shared/__tests__/join-request.test.ts +96 -0
  290. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  291. package/src/shared/kb-layout.test.ts +2 -2
  292. package/src/test-setup.ts +18 -0
  293. package/kb-template/Agents/README.md +0 -38
  294. package/kb-template/CLAUDE.md +0 -419
  295. package/kb-template/Data/.gitkeep +0 -0
  296. package/kb-template/Data/README.md +0 -39
  297. package/kb-template/KnowledgeBase/README.md +0 -37
  298. package/kb-template/Pipelines/.gitkeep +0 -0
  299. package/kb-template/Pipelines/README.md +0 -38
  300. package/kb-template/Skills/.gitkeep +0 -0
  301. package/kb-template/Skills/README.md +0 -32
  302. package/kb-template/Tools/.gitkeep +0 -0
  303. package/kb-template/Tools/README.md +0 -42
  304. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -0,0 +1,317 @@
1
+ import express from 'express';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import type { IAdminAccessService } from '../admin/admin.interface.js';
5
+ import {
6
+ DeploymentSettingsService,
7
+ SettingsValidationError,
8
+ validateHttpsRemote,
9
+ } from './deployment-settings.service.js';
10
+ import {
11
+ configureBranchModel,
12
+ isBranchModelConfigured,
13
+ validateBranchModel,
14
+ } from '@bevel-software/platform-shared';
15
+ import '../auth/auth.middleware.js'; // Express Request augmentation
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ /**
20
+ * First-run setup — the deployment's own configuration, for the things that
21
+ * used to be environment-only.
22
+ *
23
+ * WHY THESE ROUTES ARE AUTHENTICATED BUT NOT KB-DEPENDENT: the whole point is
24
+ * to be reachable on a deployment that has no knowledge base yet. That works
25
+ * because the bootstrap admin (`ADMIN_EMAIL`) is recognised without consulting
26
+ * `roles.yaml` — `AdminAccessService` short-circuits on it before any clone is
27
+ * attempted — so the one person who can finish setup can always sign in, and
28
+ * nobody else is let near it.
29
+ */
30
+ export function createSetupRoutes(
31
+ settings: DeploymentSettingsService,
32
+ adminAccess: IAdminAccessService,
33
+ ): express.Router {
34
+ const router = express.Router();
35
+
36
+ const requireAdmin: express.RequestHandler = async (req, res, next) => {
37
+ if (!(await adminAccess.isAdmin(req.userEmail))) {
38
+ res.status(403).json({ error: 'Admins only' });
39
+ return;
40
+ }
41
+ next();
42
+ };
43
+
44
+ /**
45
+ * Whether the deployment is usable, and — for an admin — what is missing.
46
+ *
47
+ * Deliberately readable by ANY signed-in user, because everyone needs the
48
+ * answer: a non-admin who arrives mid-setup gets told the deployment is
49
+ * still being configured instead of a broken file tree. Only the admin
50
+ * branch carries the settings themselves, and no branch ever carries a
51
+ * secret's value.
52
+ */
53
+ router.get('/setup/status', async (req, res) => {
54
+ const complete = isComplete(settings);
55
+ if (!(await adminAccess.isAdmin(req.userEmail))) {
56
+ res.json({ complete, isAdmin: false });
57
+ return;
58
+ }
59
+ res.json({
60
+ complete,
61
+ awaitingRestart: awaitingRestart(settings),
62
+ isAdmin: true,
63
+ settings: settings.describe(),
64
+ });
65
+ });
66
+
67
+ /**
68
+ * Save settings. Validated and written as ONE batch — a repository URL
69
+ * stored without the token that reads it is a deployment that fails at its
70
+ * first clone, so a partial write is never better than none.
71
+ */
72
+ router.post('/setup/settings', requireAdmin, async (req, res) => {
73
+ const body = (req.body ?? {}) as { settings?: Record<string, unknown> };
74
+ const entries: Record<string, string> = {};
75
+ for (const [key, value] of Object.entries(body.settings ?? {})) {
76
+ if (typeof value !== 'string') {
77
+ res.status(400).json({ error: `"${key}" must be a string.` });
78
+ return;
79
+ }
80
+ entries[key] = value;
81
+ }
82
+ try {
83
+ const { restartRequired } = await settings.save(entries, req.userId ?? null);
84
+ /**
85
+ * Apply the branch model to THIS process, so pressing Save finishes
86
+ * setup instead of asking for a restart.
87
+ *
88
+ * Only when it was not already configured — reconfiguring a live
89
+ * deployment mid-flight would swap the branches out from under sessions
90
+ * that are using them, which is a different and much less welcome
91
+ * feature. Going from "none" to "some" has nobody to disturb: the app is
92
+ * gated shut until exactly this moment.
93
+ *
94
+ * The services that need it read it when they use it rather than
95
+ * capturing it at construction, which is what makes applying it here
96
+ * enough.
97
+ */
98
+ if (!isBranchModelConfigured()) {
99
+ const model = {
100
+ defaultBranch: settings.resolve('defaultBranch'),
101
+ protectedBranches: settings.resolve('protectedBranches'),
102
+ };
103
+ if (!validateBranchModel(model)) configureBranchModel(model);
104
+ }
105
+ res.json({
106
+ ok: true,
107
+ restartRequired,
108
+ complete: isComplete(settings),
109
+ awaitingRestart: awaitingRestart(settings),
110
+ settings: settings.describe(),
111
+ });
112
+ } catch (err) {
113
+ if (err instanceof SettingsValidationError) {
114
+ res.status(400).json({ error: 'Some settings need fixing.', problems: err.problems });
115
+ return;
116
+ }
117
+ // Logged in full, returned generic: a driver message here would hand back
118
+ // the schema or the connection string.
119
+ console.error('[setup] save failed:', err instanceof Error ? err.message : String(err));
120
+ res.status(500).json({ error: 'Could not save these settings.' });
121
+ }
122
+ });
123
+
124
+ /**
125
+ * Try the credentials against the real remote, BEFORE anything is saved.
126
+ *
127
+ * This is the reason the screen is worth more than the environment variables
128
+ * it replaces. `ls-remote` is the cheapest operation that proves all three
129
+ * values at once — the URL resolves, the token authenticates, and the
130
+ * username is the one this host expects — and it answers in a second instead
131
+ * of surfacing as a failed clone at some later, unrelated moment.
132
+ *
133
+ * Values are taken from the request when supplied so an admin can test what
134
+ * they typed rather than what is stored, and fall back to what is in effect
135
+ * (which is how "test the token I saved last week" works).
136
+ */
137
+ router.post('/setup/test-connection', requireAdmin, async (req, res) => {
138
+ const body = (req.body ?? {}) as Record<string, unknown>;
139
+ const supplied = (key: string): string | null => {
140
+ const value = body[key];
141
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
142
+ };
143
+ const url = supplied('kbRepoUrl') ?? settings.resolve('kbRepoUrl');
144
+ // Unlike the token, the username is not a secret and carries a default —
145
+ // it is which name the host expects beside the token, not a credential.
146
+ const username =
147
+ supplied('gitUsername') ?? (settings.resolve('gitUsername') || 'x-access-token');
148
+
149
+ if (!url) {
150
+ res.status(400).json({ ok: false, error: 'Enter the repository URL first.' });
151
+ return;
152
+ }
153
+
154
+ /**
155
+ * THE CONFIGURED TOKEN ONLY EVER GOES TO THE CONFIGURED REPOSITORY.
156
+ *
157
+ * The stored token is deliberately unreadable — `describe()` omits it, so
158
+ * an admin can replace it but never see it. Falling back to it for whatever
159
+ * URL the request names would hand that value straight back: point the test
160
+ * at a host you control, read it out of the request. Admin-gated, but the
161
+ * whole point of not returning it is that being an admin is not the same as
162
+ * being allowed to hold it.
163
+ *
164
+ * So a request that names a DIFFERENT repository has to bring its own
165
+ * credential. Testing what is already configured — the "does the token I
166
+ * saved last week still work?" case — is unaffected.
167
+ */
168
+ const suppliedToken = supplied('gitToken');
169
+ const testingConfiguredRepo = url === settings.resolve('kbRepoUrl');
170
+ if (!suppliedToken && !testingConfiguredRepo) {
171
+ res.status(400).json({
172
+ ok: false,
173
+ error: 'Enter the access token for that repository — the saved one is only used with the repository it was saved for.',
174
+ });
175
+ return;
176
+ }
177
+ const token = suppliedToken ?? (testingConfiguredRepo ? settings.resolve('gitToken') : '');
178
+ // The SAME rule the setting is validated by, applied before the value ever
179
+ // reaches git. Without it this endpoint is argument injection: a value
180
+ // beginning `--upload-pack=` makes git run a command of the caller's
181
+ // choosing, and `ext::sh -c …` is a transport whose entire purpose is to
182
+ // execute one. Both are admin-only, but "admin" is not "may run arbitrary
183
+ // commands as the server process".
184
+ const urlProblem = validateHttpsRemote(url);
185
+ if (urlProblem) {
186
+ res.status(400).json({ ok: false, error: urlProblem });
187
+ return;
188
+ }
189
+ if (!/^[A-Za-z0-9._-]+$/.test(username)) {
190
+ // Interpolated into the credential-helper snippet below.
191
+ res.status(400).json({ ok: false, error: 'The username contains unsupported characters.' });
192
+ return;
193
+ }
194
+
195
+ try {
196
+ // The helper reads the token from the environment at call time, so it
197
+ // never appears in argv (and so never in a process listing or a crash
198
+ // dump). Same shape the real clone uses.
199
+ const args = ['-c', 'credential.helper=', ...(token
200
+ ? ['-c', `credential.helper=!f() { echo "username=${username}"; echo "password=$BEVEL_TEST_TOKEN"; }; f`]
201
+ : []),
202
+ // `--end-of-options` on top of the validation above: belt and braces,
203
+ // so nothing that arrives here can ever be read as a flag.
204
+ 'ls-remote', '--heads', '--end-of-options', url];
205
+ const { stdout } = await execFileAsync('git', args, {
206
+ timeout: 20_000,
207
+ env: {
208
+ ...process.env,
209
+ BEVEL_TEST_TOKEN: token,
210
+ // Never let git stop for a prompt: without this a bad credential
211
+ // hangs the request until the timeout instead of failing.
212
+ GIT_TERMINAL_PROMPT: '0',
213
+ GIT_ASKPASS: 'echo',
214
+ },
215
+ });
216
+ const lines = stdout.split('\n');
217
+ // `<sha>\trefs/heads/<name>` — tag refs and the bare HEAD row are not
218
+ // branches, so they are filtered rather than sliced blindly.
219
+ const branches = lines
220
+ .filter((line) => !line.startsWith('ref:'))
221
+ .map((line) => line.split('\t')[1]?.trim())
222
+ .filter((ref): ref is string => !!ref && ref.startsWith('refs/heads/'))
223
+ .map((ref) => ref.slice('refs/heads/'.length));
224
+ // `ref: refs/heads/<name>\tHEAD` — what the remote calls its own trunk.
225
+ const defaultBranch =
226
+ lines
227
+ .find((line) => line.startsWith('ref:') && line.trimEnd().endsWith('HEAD'))
228
+ ?.slice('ref: refs/heads/'.length)
229
+ .split('\t')[0]
230
+ ?.trim() || null;
231
+ res.json({
232
+ ok: true,
233
+ // An EMPTY repository is a success, not a failure — seeding one is a
234
+ // supported path, and saying "no branches yet" beats an error that
235
+ // reads like the credentials are wrong.
236
+ empty: branches.length === 0,
237
+ branches,
238
+ defaultBranch,
239
+ });
240
+ } catch (err) {
241
+ res.status(200).json({ ok: false, error: explainGitFailure(err, token) });
242
+ }
243
+ });
244
+
245
+ return router;
246
+ }
247
+
248
+ /**
249
+ * Whether the STORED configuration answers everything the deployment needs.
250
+ *
251
+ * The KB needs a URL and a token — the username and the directory name both
252
+ * have working defaults, so neither can block a start. The branch model has no
253
+ * default ON PURPOSE: guessing `main` would silently point a deployment at the
254
+ * wrong branch and let the protected-branch guards apply to nothing, which is
255
+ * worse than asking.
256
+ *
257
+ * SSO is deliberately absent. It is configuration, not a prerequisite — a
258
+ * deployment signs in perfectly well without it, and gating on it would lock
259
+ * an admin out of the screen where they would set it up.
260
+ */
261
+ export function settingsAnswered(settings: DeploymentSettingsService): boolean {
262
+ const kb = Boolean(settings.resolve('kbRepoUrl') && settings.resolve('gitToken'));
263
+ const branches =
264
+ validateBranchModel({
265
+ defaultBranch: settings.resolve('defaultBranch'),
266
+ protectedBranches: settings.resolve('protectedBranches'),
267
+ }) === null;
268
+ return kb && branches;
269
+ }
270
+
271
+ /**
272
+ * Whether THIS PROCESS can actually serve — which is not the same question,
273
+ * and conflating them opened the gate onto a broken app.
274
+ *
275
+ * The branch model is applied once, during boot: services take `DEFAULT_BRANCH`
276
+ * at construction and the browser is served it before it renders. Saving it
277
+ * therefore answers the question without changing the answer this process
278
+ * holds — so a deployment configured through the setup screen reported itself
279
+ * complete while every workspace call still failed with
280
+ * `Invalid branch name ""`.
281
+ *
282
+ * Requiring the model to be IN EFFECT keeps the gate shut until the restart
283
+ * that puts it there. {@link awaitingRestart} is what tells the screen to ask
284
+ * for one rather than claim a field is missing.
285
+ */
286
+ export function isComplete(settings: DeploymentSettingsService): boolean {
287
+ return settingsAnswered(settings) && isBranchModelConfigured();
288
+ }
289
+
290
+ /** Answered, but not yet in effect: everything is stored, the process is stale. */
291
+ export function awaitingRestart(settings: DeploymentSettingsService): boolean {
292
+ return settingsAnswered(settings) && !isBranchModelConfigured();
293
+ }
294
+
295
+ /**
296
+ * Turn git's stderr into something an admin can act on. Deliberately narrow:
297
+ * the raw text is echoed only when it matches nothing known, and the token is
298
+ * scrubbed from it first — `ls-remote` failures have been known to quote the
299
+ * credential back.
300
+ */
301
+ function explainGitFailure(err: unknown, token: string): string {
302
+ const raw = err instanceof Error ? `${err.message}` : String(err);
303
+ const text = token ? raw.replaceAll(token, '***') : raw;
304
+ if (/timed out|ETIMEDOUT/i.test(text)) {
305
+ return 'The host did not answer in time. Check the URL, and that this server can reach it.';
306
+ }
307
+ if (/Authentication failed|could not read Username|invalid credentials|403/i.test(text)) {
308
+ return 'The host rejected those credentials. Check the token, and that the username matches the host (GitHub x-access-token, GitLab oauth2, Bitbucket x-token-auth).';
309
+ }
310
+ if (/not found|repository .* does not exist|404/i.test(text)) {
311
+ return 'No repository at that URL — or the token cannot see it.';
312
+ }
313
+ if (/could not resolve host|unable to access|SSL|certificate/i.test(text)) {
314
+ return 'Could not reach that host from this server. Check the URL and any network egress rules.';
315
+ }
316
+ return text.split('\n').slice(0, 3).join(' ').slice(0, 400);
317
+ }
@@ -0,0 +1,240 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import type { ChangeRequest, IWorkflowService } from '@bevel-software/platform-shared';
3
+ import { PendingSkillsService } from '../pending-skills.service.js';
4
+ import { hashEmail } from '../../../shared/hash-email.js';
5
+ import type { WorkspaceService } from '../../workspace/workspace.service.js';
6
+ import type { IAccessControl } from '../../access/access-control.interface.js';
7
+ import type { ISkillService, SkillSummary } from '../skills.contract.js';
8
+
9
+ /**
10
+ * The half of the catalog that is NOT on the default branch.
11
+ *
12
+ * The bug this exists for: an agent opened a change request adding a skill, and
13
+ * the skill was nowhere in the product until somebody merged it — not for the
14
+ * author, not for the person who had to approve it. So the tests below are
15
+ * mostly about who gets to see one, because "show it" and "show it to the wrong
16
+ * people" are the two ways to get this wrong.
17
+ */
18
+
19
+ const AUTHOR = 'ali@bevel.software';
20
+ const ADMIN = 'olga@bevel.software';
21
+ const BYSTANDER = 'sam@bevel.software';
22
+
23
+ const SKILL_MD = `---
24
+ name: weekly-newsletter
25
+ version: 0.1.0
26
+ description: Drafts the Friday newsletter.
27
+ ---
28
+
29
+ # Weekly newsletter
30
+ `;
31
+
32
+ function cr(over: Partial<ChangeRequest> = {}): ChangeRequest {
33
+ return {
34
+ number: 7,
35
+ title: 'Add the weekly-newsletter skill',
36
+ authorId: hashEmail(AUTHOR),
37
+ author: { login: 'user-abc', name: 'service' },
38
+ appAuthor: { name: 'Ali Raza' },
39
+ branch: 'agent/weekly-newsletter',
40
+ base: 'main',
41
+ state: 'open',
42
+ createdAt: '2026-08-06T09:00:00.000Z',
43
+ touchedNodePaths: ['Groups/Engineering/weekly-newsletter/SKILL.md'],
44
+ review: { approvals: 0, changesRequested: 0, pendingLogins: [] },
45
+ url: '/change-requests/7',
46
+ ...over,
47
+ };
48
+ }
49
+
50
+ /** Files present on a change request's branch, keyed `<branch>:<path>`. */
51
+ function harness(opts: {
52
+ crs: ChangeRequest[];
53
+ released?: SkillSummary[];
54
+ branchFiles?: Record<string, string>;
55
+ /** Emails allowed to write each path. Absent path ⇒ nobody. */
56
+ writers?: Record<string, string[]>;
57
+ }) {
58
+ const branchFiles = opts.branchFiles ?? {
59
+ 'agent/weekly-newsletter:Groups/Engineering/weekly-newsletter/SKILL.md': SKILL_MD,
60
+ };
61
+ const workspaceService = {
62
+ ensureRemotesFetched: async () => undefined,
63
+ readFileAtRef: async (_ws: string, ref: string, rel: string) =>
64
+ branchFiles[`${ref.replace(/^origin\//, '')}:${rel}`] ?? null,
65
+ } as unknown as WorkspaceService;
66
+
67
+ const accessControl = {
68
+ canWriteBatch: async (_ws: string, email: string, paths: string[]) =>
69
+ new Map(paths.map((p) => [p, (opts.writers?.[p] ?? []).includes(email)])),
70
+ } as unknown as IAccessControl;
71
+
72
+ const skillService = {
73
+ listSkills: async () => opts.released ?? [],
74
+ } as unknown as ISkillService;
75
+
76
+ const workflow = {
77
+ listChangeRequests: async () => opts.crs,
78
+ } as unknown as IWorkflowService;
79
+
80
+ return new PendingSkillsService(workspaceService, accessControl, skillService, workflow);
81
+ }
82
+
83
+ const ADMIN_WRITES = {
84
+ 'Groups/Engineering/weekly-newsletter/SKILL.md': [ADMIN],
85
+ };
86
+
87
+ describe('PendingSkillsService', () => {
88
+ test('surfaces a skill that exists only on a change request, to its author', async () => {
89
+ const pending = await harness({ crs: [cr()], writers: ADMIN_WRITES }).listPendingSkills(AUTHOR);
90
+
91
+ expect(pending).toEqual([
92
+ {
93
+ name: 'weekly-newsletter',
94
+ description: 'Drafts the Friday newsletter.',
95
+ version: '0.1.0',
96
+ path: 'Groups/Engineering/weekly-newsletter',
97
+ changeRequestNumber: 7,
98
+ branch: 'agent/weekly-newsletter',
99
+ authorName: 'Ali Raza',
100
+ createdAt: '2026-08-06T09:00:00.000Z',
101
+ isAuthor: true,
102
+ },
103
+ ]);
104
+ });
105
+
106
+ test('surfaces it to whoever could approve it, marked as not theirs', async () => {
107
+ const pending = await harness({ crs: [cr()], writers: ADMIN_WRITES }).listPendingSkills(ADMIN);
108
+ expect(pending.map((p) => [p.name, p.isAuthor])).toEqual([['weekly-newsletter', false]]);
109
+ });
110
+
111
+ /**
112
+ * The decision that shapes this surface: a proposal is between its author and
113
+ * the people who can approve it, and nobody else. A member of the group who
114
+ * cannot write the folder is not one of those people.
115
+ */
116
+ test('hides it from everyone else', async () => {
117
+ expect(await harness({ crs: [cr()], writers: ADMIN_WRITES }).listPendingSkills(BYSTANDER))
118
+ .toEqual([]);
119
+ });
120
+
121
+ /**
122
+ * Fail closed like the catalog: an access lookup that cannot answer is a
123
+ * denial, never a disclosure. Without this a broken access tree would show
124
+ * every open proposal to everyone.
125
+ */
126
+ test('shows nothing to a non-author when the access tree cannot be read', async () => {
127
+ // No writer on any path is what an unreadable tree looks like from here.
128
+ const broken = harness({ crs: [cr()], writers: {} });
129
+ expect(await broken.listPendingSkills(ADMIN)).toEqual([]);
130
+ // …and the author still sees their own, because that verdict needs no tree.
131
+ expect(await broken.listPendingSkills(AUTHOR)).toHaveLength(1);
132
+ });
133
+
134
+ /**
135
+ * A change request that EDITS a released skill is not a pending skill — the
136
+ * skill page already carries those, and duplicating them here would put a
137
+ * second, ghost card beside every skill under review.
138
+ */
139
+ test('ignores a change request against a skill that is already released', async () => {
140
+ const svc = harness({
141
+ crs: [cr()],
142
+ released: [
143
+ {
144
+ name: 'weekly-newsletter',
145
+ description: 'Drafts the Friday newsletter.',
146
+ path: 'Groups/Engineering/weekly-newsletter',
147
+ },
148
+ ],
149
+ writers: ADMIN_WRITES,
150
+ });
151
+ expect(await svc.listPendingSkills(AUTHOR)).toEqual([]);
152
+ });
153
+
154
+ test('ignores touched paths that are not a skill of their own', async () => {
155
+ const svc = harness({
156
+ crs: [
157
+ cr({
158
+ touchedNodePaths: [
159
+ 'Groups/Engineering/access.md',
160
+ 'Groups/Engineering/SKILL.md', // a group folder is not a skill
161
+ 'Groups/Engineering/weekly-newsletter/reference.md',
162
+ ],
163
+ }),
164
+ ],
165
+ writers: ADMIN_WRITES,
166
+ });
167
+ expect(await svc.listPendingSkills(AUTHOR)).toEqual([]);
168
+ });
169
+
170
+ /**
171
+ * The proposal is read at the request's own branch. Reading the default
172
+ * branch would find nothing and drop the card — which is the original bug.
173
+ */
174
+ test('drops a proposal whose SKILL.md cannot be read on its branch', async () => {
175
+ const svc = harness({ crs: [cr()], branchFiles: {}, writers: ADMIN_WRITES });
176
+ expect(await svc.listPendingSkills(AUTHOR)).toEqual([]);
177
+ });
178
+
179
+ test('ignores change requests that are not open', async () => {
180
+ const svc = harness({ crs: [cr({ state: 'merged' })], writers: ADMIN_WRITES });
181
+ expect(await svc.listPendingSkills(AUTHOR)).toEqual([]);
182
+ });
183
+
184
+ test('falls back to the folder name when the frontmatter declares no id', async () => {
185
+ const svc = harness({
186
+ crs: [cr()],
187
+ branchFiles: {
188
+ 'agent/weekly-newsletter:Groups/Engineering/weekly-newsletter/SKILL.md':
189
+ '---\ndescription: No name declared.\n---\n\n# Body\n',
190
+ },
191
+ writers: ADMIN_WRITES,
192
+ });
193
+ const [pending] = await svc.listPendingSkills(AUTHOR);
194
+ expect(pending.name).toBe('weekly-newsletter');
195
+ expect(pending.version).toBeUndefined();
196
+ });
197
+
198
+ /**
199
+ * This hangs off the library's list load. A workflow service that cannot
200
+ * answer must cost the reader an empty review shelf, not their whole library.
201
+ */
202
+ test('degrades to nothing pending when the change requests cannot be listed', async () => {
203
+ const workflow = {
204
+ listChangeRequests: async () => {
205
+ throw new Error('offline');
206
+ },
207
+ } as unknown as IWorkflowService;
208
+ const svc = new PendingSkillsService(
209
+ {} as unknown as WorkspaceService,
210
+ {} as unknown as IAccessControl,
211
+ { listSkills: async () => [] } as unknown as ISkillService,
212
+ workflow,
213
+ );
214
+ await expect(svc.listPendingSkills(AUTHOR)).resolves.toEqual([]);
215
+ });
216
+
217
+ test('oldest first — the request that has waited longest is the one to answer', async () => {
218
+ const svc = harness({
219
+ crs: [
220
+ cr({ number: 9, createdAt: '2026-08-06T12:00:00.000Z' }),
221
+ cr({
222
+ number: 3,
223
+ createdAt: '2026-08-01T12:00:00.000Z',
224
+ branch: 'agent/older',
225
+ touchedNodePaths: ['Groups/Engineering/older/SKILL.md'],
226
+ }),
227
+ ],
228
+ branchFiles: {
229
+ 'agent/weekly-newsletter:Groups/Engineering/weekly-newsletter/SKILL.md': SKILL_MD,
230
+ 'agent/older:Groups/Engineering/older/SKILL.md':
231
+ '---\nname: older\ndescription: Older.\n---\n',
232
+ },
233
+ writers: {
234
+ ...ADMIN_WRITES,
235
+ 'Groups/Engineering/older/SKILL.md': [ADMIN],
236
+ },
237
+ });
238
+ expect((await svc.listPendingSkills(ADMIN)).map((p) => p.changeRequestNumber)).toEqual([3, 9]);
239
+ });
240
+ });
@@ -50,14 +50,14 @@ describe('SkillService', () => {
50
50
 
51
51
  beforeEach(async () => {
52
52
  root = await mkdtemp(join(tmpdir(), 'skills-'));
53
- const skills = join(root, wsId, KB_DIR, 'Skills');
53
+ const skills = join(root, wsId, KB_DIR, 'Groups');
54
54
  await mkdir(join(skills, 'rfi', 'scripts'), { recursive: true });
55
55
  await writeFile(join(skills, 'rfi', 'SKILL.md'), RFI_SKILL);
56
56
  await writeFile(join(skills, 'rfi', 'scripts', 'build_xlsx.py'), 'print("xlsx")\n');
57
57
  // A folder with no SKILL.md is not a skill.
58
58
  await mkdir(join(skills, 'not-a-skill'), { recursive: true });
59
59
  await writeFile(join(skills, 'not-a-skill', 'README.md'), '# nope\n');
60
- // A skill nested in a category subfolder (Skills/Development/coding-guidelines).
60
+ // A skill nested in a category subfolder (Groups/Development/coding-guidelines).
61
61
  await mkdir(join(skills, 'Development', 'coding-guidelines'), { recursive: true });
62
62
  await writeFile(
63
63
  join(skills, 'Development', 'coding-guidelines', 'SKILL.md'),
@@ -72,7 +72,7 @@ describe('SkillService', () => {
72
72
  expect(skills).toHaveLength(2);
73
73
  const rfi = skills.find((s) => s.name === 'rfi')!;
74
74
  expect(rfi).toBeDefined();
75
- expect(rfi.path).toBe('Skills/rfi');
75
+ expect(rfi.path).toBe('Groups/rfi');
76
76
  expect(rfi.version).toBe('1.4.0');
77
77
  expect(rfi.description).not.toBe('|');
78
78
  expect(rfi.description).toContain('Specialist RFI responder');
@@ -83,19 +83,19 @@ describe('SkillService', () => {
83
83
  const skills = await svc().listSkills();
84
84
  const cg = skills.find((s) => s.name === 'coding-guidelines')!;
85
85
  expect(cg).toBeDefined();
86
- expect(cg.path).toBe('Skills/Development/coding-guidelines');
86
+ expect(cg.path).toBe('Groups/Development/coding-guidelines');
87
87
  expect(cg.description).toBe('Coding guidelines.');
88
88
 
89
89
  const res = await svc().getSkill('user@x.eu', 'coding-guidelines');
90
90
  expect(res.ok).toBe(true);
91
91
  if (res.ok && res.kind === 'skill') {
92
- expect(res.skill.path).toBe('Skills/Development/coding-guidelines');
92
+ expect(res.skill.path).toBe('Groups/Development/coding-guidelines');
93
93
  expect(res.skill.body).toContain('# Guidelines');
94
94
  }
95
95
  });
96
96
 
97
97
  test('refuses a colliding id (no auto-suffix) — the shared dedup rule', async () => {
98
- const skills = join(root, wsId, KB_DIR, 'Skills');
98
+ const skills = join(root, wsId, KB_DIR, 'Groups');
99
99
  // Two skills resolve to the same id `dup` (no frontmatter id/name → folder name).
100
100
  await mkdir(join(skills, 'Zeta', 'dup'), { recursive: true });
101
101
  await writeFile(join(skills, 'Zeta', 'dup', 'SKILL.md'), '---\ndescription: zeta dup\n---\n\n# Z\n');
@@ -104,28 +104,28 @@ describe('SkillService', () => {
104
104
 
105
105
  const list = await svc().listSkills();
106
106
  // Only the first (smallest path) survives; the duplicate is dropped, not suffixed.
107
- expect(list.filter((s) => s.name === 'dup').map((s) => s.path)).toEqual(['Skills/Alpha/dup']);
107
+ expect(list.filter((s) => s.name === 'dup').map((s) => s.path)).toEqual(['Groups/Alpha/dup']);
108
108
  expect(list.find((s) => s.name === 'dup2')).toBeUndefined();
109
109
 
110
110
  const a = await svc().getSkill('user@x.eu', 'dup');
111
- expect(a.ok && a.kind === 'skill' && a.skill.path).toBe('Skills/Alpha/dup');
111
+ expect(a.ok && a.kind === 'skill' && a.skill.path).toBe('Groups/Alpha/dup');
112
112
  });
113
113
 
114
114
  test('frontmatter `id`/`name` overrides the folder name for identity', async () => {
115
- const skills = join(root, wsId, KB_DIR, 'Skills');
115
+ const skills = join(root, wsId, KB_DIR, 'Groups');
116
116
  await mkdir(join(skills, 'folderx'), { recursive: true });
117
117
  await writeFile(join(skills, 'folderx', 'SKILL.md'), '---\nid: my_skill\ndescription: d\n---\n\n# X\n');
118
118
  const list = await svc().listSkills();
119
- expect(list.find((s) => s.name === 'my_skill')?.path).toBe('Skills/folderx');
119
+ expect(list.find((s) => s.name === 'my_skill')?.path).toBe('Groups/folderx');
120
120
  });
121
121
 
122
122
  test('getSkill returns body, folder path, files and allowed-tools', async () => {
123
123
  const res = await svc().getSkill('user@x.eu', 'rfi');
124
124
  expect(res.ok).toBe(true);
125
125
  if (res.ok && res.kind === 'skill') {
126
- expect(res.skill.path).toBe('Skills/rfi');
126
+ expect(res.skill.path).toBe('Groups/rfi');
127
127
  expect(res.skill.body).toContain('# /rfi: Specialist RFI Responder');
128
- expect(res.skill.files).toEqual(['Skills/rfi/scripts/build_xlsx.py']);
128
+ expect(res.skill.files).toEqual(['Groups/rfi/scripts/build_xlsx.py']);
129
129
  expect(res.skill.allowedTools).toEqual(['Bash', 'Read']);
130
130
  }
131
131
  });
@@ -134,7 +134,7 @@ describe('SkillService', () => {
134
134
  const res = await svc().getSkill('user@x.eu', 'rfi', 'scripts/build_xlsx.py');
135
135
  expect(res.ok).toBe(true);
136
136
  if (res.ok && res.kind === 'file') {
137
- expect(res.file.path).toBe('Skills/rfi/scripts/build_xlsx.py');
137
+ expect(res.file.path).toBe('Groups/rfi/scripts/build_xlsx.py');
138
138
  expect(res.file.content).toContain('print("xlsx")');
139
139
  }
140
140
  });
@@ -1,10 +1,13 @@
1
1
  export { SkillService } from './skills.service.js';
2
+ export { PendingSkillsService } from './pending-skills.service.js';
2
3
  export { registerSkillsTools } from './skills.tools.js';
3
4
  export { createSkillsRoutes } from './skills.routes.js';
4
5
  export type {
5
6
  ISkillService,
7
+ IPendingSkillService,
6
8
  Skill,
7
9
  SkillSummary,
8
10
  SkillFileContent,
11
+ PendingSkill,
9
12
  GetSkillResult,
10
13
  } from './skills.contract.js';