@bevel-software/platform-core-backend 0.4.0 → 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.
- package/dist/core/core-ports.d.ts +15 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +40 -6
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +17 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +153 -29
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +70 -21
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +134 -58
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +6 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +74 -5
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +116 -15
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +93 -0
- package/dist/modules/access/access-declarations.d.ts.map +1 -0
- package/dist/modules/access/access-declarations.js +168 -0
- package/dist/modules/access/access-declarations.js.map +1 -0
- package/dist/modules/access/access-mutation.service.js +4 -4
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +16 -1
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -21
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +51 -0
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +8 -0
- package/dist/modules/access/creator-access.d.ts.map +1 -1
- package/dist/modules/access/creator-access.js +21 -2
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/admin/admin-access.service.d.ts +1 -1
- package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
- package/dist/modules/admin/admin-access.service.js +3 -2
- package/dist/modules/admin/admin-access.service.js.map +1 -1
- package/dist/modules/auth/auth.routes.d.ts.map +1 -1
- package/dist/modules/auth/auth.routes.js +50 -1
- package/dist/modules/auth/auth.routes.js.map +1 -1
- package/dist/modules/auth/auth.service.d.ts +39 -10
- package/dist/modules/auth/auth.service.d.ts.map +1 -1
- package/dist/modules/auth/auth.service.js +38 -16
- package/dist/modules/auth/auth.service.js.map +1 -1
- package/dist/modules/database/core-schema.d.ts +124 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +45 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/groups/group-provision.service.d.ts +130 -0
- package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
- package/dist/modules/groups/group-provision.service.js +288 -0
- package/dist/modules/groups/group-provision.service.js.map +1 -0
- package/dist/modules/groups/groups.contract.d.ts +106 -0
- package/dist/modules/groups/groups.contract.d.ts.map +1 -0
- package/dist/modules/groups/groups.contract.js +36 -0
- package/dist/modules/groups/groups.contract.js.map +1 -0
- package/dist/modules/groups/groups.routes.d.ts +42 -0
- package/dist/modules/groups/groups.routes.d.ts.map +1 -0
- package/dist/modules/groups/groups.routes.js +379 -0
- package/dist/modules/groups/groups.routes.js.map +1 -0
- package/dist/modules/groups/groups.service.d.ts +60 -0
- package/dist/modules/groups/groups.service.d.ts.map +1 -0
- package/dist/modules/groups/groups.service.js +172 -0
- package/dist/modules/groups/groups.service.js.map +1 -0
- package/dist/modules/groups/index.d.ts +7 -0
- package/dist/modules/groups/index.d.ts.map +1 -0
- package/dist/modules/groups/index.js +6 -0
- package/dist/modules/groups/index.js.map +1 -0
- package/dist/modules/groups/join-proposals.d.ts +53 -0
- package/dist/modules/groups/join-proposals.d.ts.map +1 -0
- package/dist/modules/groups/join-proposals.js +67 -0
- package/dist/modules/groups/join-proposals.js.map +1 -0
- package/dist/modules/groups/join-requests.service.d.ts +81 -0
- package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
- package/dist/modules/groups/join-requests.service.js +135 -0
- package/dist/modules/groups/join-requests.service.js.map +1 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
- package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
- package/dist/modules/settings/deployment-settings.service.js +368 -0
- package/dist/modules/settings/deployment-settings.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +49 -0
- package/dist/modules/settings/setup.routes.d.ts.map +1 -0
- package/dist/modules/settings/setup.routes.js +292 -0
- package/dist/modules/settings/setup.routes.js.map +1 -0
- package/dist/modules/skills/index.d.ts +2 -1
- package/dist/modules/skills/index.d.ts.map +1 -1
- package/dist/modules/skills/index.js +1 -0
- package/dist/modules/skills/index.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +40 -0
- package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
- package/dist/modules/skills/pending-skills.service.js +147 -0
- package/dist/modules/skills/pending-skills.service.js.map +1 -0
- package/dist/modules/skills/skills.contract.d.ts +32 -3
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.routes.d.ts +3 -2
- package/dist/modules/skills/skills.routes.d.ts.map +1 -1
- package/dist/modules/skills/skills.routes.js +18 -1
- package/dist/modules/skills/skills.routes.js.map +1 -1
- package/dist/modules/skills/skills.service.d.ts +22 -0
- package/dist/modules/skills/skills.service.d.ts.map +1 -1
- package/dist/modules/skills/skills.service.js +24 -12
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/index.d.ts +1 -1
- package/dist/modules/tool-manuals/index.d.ts.map +1 -1
- package/dist/modules/tool-manuals/index.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
- package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
- package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
- package/dist/modules/workflow/event-bus.d.ts +11 -0
- package/dist/modules/workflow/event-bus.d.ts.map +1 -1
- package/dist/modules/workflow/event-bus.js +22 -0
- package/dist/modules/workflow/event-bus.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +9 -0
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +16 -0
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts +1 -1
- package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
- package/dist/modules/workflow/git/branch-name.js +1 -1
- package/dist/modules/workflow/git/branch-name.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +31 -2
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +157 -54
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
- package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/pull-request.service.js +19 -9
- package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
- package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +95 -14
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +97 -5
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +393 -114
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
- package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
- package/dist/modules/workspace/bevel-ignore.js +5 -1
- package/dist/modules/workspace/bevel-ignore.js.map +1 -1
- package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +228 -51
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts +2 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +51 -18
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +16 -4
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +36 -9
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +8 -3
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/kb-layout.test.js +2 -2
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/dist/test-setup.js +18 -0
- package/dist/test-setup.js.map +1 -0
- package/kb-template/.bevelignore +6 -2
- package/kb-template/AGENTS.md +272 -0
- package/kb-template/KnowledgeBase/How to get started.md +65 -0
- package/kb-template/access.md +36 -36
- package/migrations/0002_user_onboarding_done.sql +1 -0
- package/migrations/0003_deployment_settings.sql +9 -0
- package/migrations/meta/0001_snapshot.json +1486 -0
- package/migrations/meta/0002_snapshot.json +1493 -0
- package/migrations/meta/0003_snapshot.json +1552 -0
- package/migrations/meta/_journal.json +15 -1
- package/package.json +3 -3
- package/src/__tests__/branch-model.test.ts +79 -0
- package/src/__tests__/core-config.admin.test.ts +114 -0
- package/src/__tests__/database-url.test.ts +70 -0
- package/src/core/core-ports.ts +15 -0
- package/src/core/create-core-server.ts +55 -6
- package/src/core/create-core-services.ts +201 -36
- package/src/core-config.ts +139 -59
- package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
- package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
- package/src/modules/access/__tests__/access-splice.test.ts +21 -0
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
- package/src/modules/access/access-control.interface.ts +11 -0
- package/src/modules/access/access-control.service.ts +113 -15
- package/src/modules/access/access-declarations.ts +251 -0
- package/src/modules/access/access-mutation.service.ts +4 -4
- package/src/modules/access/access-splice.ts +64 -21
- package/src/modules/access/access.routes.ts +61 -0
- package/src/modules/access/creator-access.ts +22 -2
- package/src/modules/admin/admin-access.service.ts +4 -3
- package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
- package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
- package/src/modules/auth/auth.routes.ts +50 -1
- package/src/modules/auth/auth.service.ts +55 -17
- package/src/modules/database/core-schema.ts +46 -0
- package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
- package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
- package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
- package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
- package/src/modules/groups/group-provision.service.ts +328 -0
- package/src/modules/groups/groups.contract.ts +107 -0
- package/src/modules/groups/groups.routes.ts +433 -0
- package/src/modules/groups/groups.service.ts +193 -0
- package/src/modules/groups/index.ts +17 -0
- package/src/modules/groups/join-proposals.ts +114 -0
- package/src/modules/groups/join-requests.service.ts +185 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
- package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
- package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
- package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
- package/src/modules/settings/deployment-settings.service.ts +419 -0
- package/src/modules/settings/setup.routes.ts +317 -0
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/index.ts +3 -0
- package/src/modules/skills/pending-skills.service.ts +168 -0
- package/src/modules/skills/skills.contract.ts +34 -3
- package/src/modules/skills/skills.routes.ts +23 -2
- package/src/modules/skills/skills.service.ts +24 -16
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
- package/src/modules/tool-manuals/index.ts +2 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
- package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
- package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
- package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
- package/src/modules/workflow/__tests__/pending-commits.enqueue-if-absent.test.ts +82 -72
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +295 -5
- package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
- package/src/modules/workflow/event-bus.ts +25 -0
- package/src/modules/workflow/file-lock.service.ts +17 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
- package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
- package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
- package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
- package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +311 -306
- package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
- package/src/modules/workflow/git/branch-name.ts +5 -1
- package/src/modules/workflow/git/git.service.ts +172 -64
- package/src/modules/workflow/git/pull-request.service.ts +22 -11
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
- package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
- package/src/modules/workflow/workflow.routes.ts +91 -13
- package/src/modules/workflow/workflow.service.ts +365 -19
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
- package/src/modules/workspace/bevel-ignore.ts +66 -62
- package/src/modules/workspace/kb-seed.interface.ts +1 -1
- package/src/modules/workspace/kb-seed.service.ts +247 -53
- package/src/modules/workspace/workspace.routes.ts +54 -18
- package/src/modules/workspace/workspace.service.ts +45 -7
- package/src/modules/workspace/workspace.tools.ts +922 -917
- package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
- package/src/shared/__tests__/join-request.test.ts +96 -0
- package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +2 -2
- package/src/test-setup.ts +18 -0
- package/kb-template/Agents/README.md +0 -38
- package/kb-template/CLAUDE.md +0 -419
- package/kb-template/Data/.gitkeep +0 -0
- package/kb-template/Data/README.md +0 -39
- package/kb-template/KnowledgeBase/README.md +0 -37
- package/kb-template/Pipelines/.gitkeep +0 -0
- package/kb-template/Pipelines/README.md +0 -38
- package/kb-template/Skills/.gitkeep +0 -0
- package/kb-template/Skills/README.md +0 -32
- package/kb-template/Tools/.gitkeep +0 -0
- package/kb-template/Tools/README.md +0 -42
- /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, '
|
|
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 (
|
|
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('
|
|
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('
|
|
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('
|
|
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, '
|
|
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(['
|
|
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('
|
|
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, '
|
|
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('
|
|
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('
|
|
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(['
|
|
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('
|
|
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';
|