@bevel-software/platform-core-backend 0.7.5 → 0.9.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/LICENSE +202 -202
- package/THIRD-PARTY-NOTICES.md +428 -454
- package/dist/core/core-ports.d.ts +8 -1
- 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 +75 -18
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +12 -5
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +69 -36
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +8 -7
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +6 -6
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +5 -5
- package/dist/modules/access/access-declarations.js +3 -3
- package/dist/modules/access/access-mutation.service.d.ts +3 -3
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +3 -3
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.js +4 -4
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts +3 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +22 -20
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +2 -2
- package/dist/modules/access/creator-access.js +5 -5
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/access/render-roles-yaml.d.ts +22 -0
- package/dist/modules/access/render-roles-yaml.d.ts.map +1 -0
- package/dist/modules/access/render-roles-yaml.js +56 -0
- package/dist/modules/access/render-roles-yaml.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +33 -5
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +44 -19
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode-names.js +5 -27
- package/dist/modules/code-mode/code-mode-names.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +29 -7
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +11 -52
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +33 -395
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/index.d.ts +7 -0
- package/dist/modules/plugins/index.d.ts.map +1 -0
- package/dist/modules/plugins/index.js +6 -0
- package/dist/modules/plugins/index.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +53 -0
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
- package/dist/modules/plugins/join-proposals.js +67 -0
- package/dist/modules/plugins/join-proposals.js.map +1 -0
- package/dist/modules/plugins/join-requests.service.d.ts +81 -0
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
- package/dist/modules/plugins/join-requests.service.js +135 -0
- package/dist/modules/plugins/join-requests.service.js.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.js +344 -0
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
- package/dist/modules/plugins/plugins.contract.d.ts +106 -0
- package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.contract.js +36 -0
- package/dist/modules/plugins/plugins.contract.js.map +1 -0
- package/dist/modules/plugins/plugins.routes.d.ts +42 -0
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.routes.js +379 -0
- package/dist/modules/plugins/plugins.routes.js.map +1 -0
- package/dist/modules/plugins/plugins.service.d.ts +60 -0
- package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.service.js +172 -0
- package/dist/modules/plugins/plugins.service.js.map +1 -0
- package/dist/modules/settings/setup.routes.d.ts +10 -1
- package/dist/modules/settings/setup.routes.d.ts.map +1 -1
- package/dist/modules/settings/setup.routes.js +111 -6
- package/dist/modules/settings/setup.routes.js.map +1 -1
- package/dist/modules/skills/pending-skills.service.d.ts +2 -2
- package/dist/modules/skills/pending-skills.service.js +7 -7
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +4 -4
- 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.service.js +5 -5
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
- 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 +13 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.js +2 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +43 -10
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/plugins-migration.d.ts +50 -0
- package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
- package/dist/modules/workspace/plugins-migration.js +379 -0
- package/dist/modules/workspace/plugins-migration.js.map +1 -0
- package/dist/modules/workspace/startup/kb-git.d.ts +23 -0
- package/dist/modules/workspace/startup/kb-git.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-git.js +86 -0
- package/dist/modules/workspace/startup/kb-git.js.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +74 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js +528 -0
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts +105 -0
- package/dist/modules/workspace/startup/on-server-start.d.ts.map +1 -0
- package/dist/modules/workspace/startup/on-server-start.js +21 -0
- package/dist/modules/workspace/startup/on-server-start.js.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts +46 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js +492 -0
- package/dist/modules/workspace/startup/steps/groups-to-plugins.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts +23 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js +69 -0
- package/dist/modules/workspace/startup/steps/roles-yaml.step.js.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts +17 -0
- package/dist/modules/workspace/startup/steps/seed-tree.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js +109 -0
- package/dist/modules/workspace/startup/steps/seed-tree.js.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts +103 -0
- package/dist/modules/workspace/startup/steps/template-files.step.d.ts.map +1 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js +337 -0
- package/dist/modules/workspace/startup/steps/template-files.step.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.js +3 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +0 -9
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +2 -30
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/kb-layout.test.js +3 -3
- package/dist/shared/kb-layout.test.js.map +1 -1
- package/dist/shared/utcp-namespace.d.ts +6 -27
- package/dist/shared/utcp-namespace.d.ts.map +1 -1
- package/dist/shared/utcp-namespace.js +6 -63
- package/dist/shared/utcp-namespace.js.map +1 -1
- package/dist/shared/variable-refs.d.ts +42 -0
- package/dist/shared/variable-refs.d.ts.map +1 -0
- package/dist/shared/variable-refs.js +60 -0
- package/dist/shared/variable-refs.js.map +1 -0
- package/kb-template/.bevelignore +1 -1
- package/kb-template/AGENTS.md +88 -35
- package/kb-template/KnowledgeBase/How to get started.md +10 -10
- package/kb-template/access.md +36 -36
- package/kb-template/gitignore.template +16 -0
- package/migrations/meta/0000_snapshot.json +1479 -1479
- package/package.json +5 -4
- package/src/assets.ts +25 -25
- package/src/core/core-ports.ts +113 -106
- package/src/core/create-core-server.ts +85 -20
- package/src/core/create-core-services.ts +83 -42
- package/src/index.ts +69 -69
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
- package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
- package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
- package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
- package/src/modules/access/__tests__/access-splice.test.ts +2 -2
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
- package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
- package/src/modules/access/__tests__/roles-admin.service.test.ts +36 -2
- package/src/modules/access/access-control.interface.ts +8 -7
- package/src/modules/access/access-control.service.ts +7 -7
- package/src/modules/access/access-declarations.ts +5 -5
- package/src/modules/access/access-mutation.service.ts +3 -3
- package/src/modules/access/access-splice.ts +4 -4
- package/src/modules/access/access.routes.ts +23 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/render-roles-yaml.ts +65 -0
- package/src/modules/access/roles-admin.service.ts +45 -16
- package/src/modules/admin/admin-access.routes.ts +29 -29
- package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
- package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
- package/src/modules/auth/rate-limit.ts +45 -45
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
- package/src/modules/code-mode/code-mode-names.ts +10 -36
- package/src/modules/code-mode/code-mode.tool.ts +27 -7
- package/src/modules/database/connection.ts +15 -15
- package/src/modules/database/schema.ts +11 -11
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
- package/src/modules/mcp/mcp.service.ts +57 -435
- package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
- package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
- package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
- package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
- package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
- package/src/modules/plugins/index.ts +17 -0
- package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
- package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
- package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
- package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
- package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
- package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
- package/src/modules/settings/__tests__/setup.routes.test.ts +124 -3
- package/src/modules/settings/setup.routes.ts +112 -5
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/pending-skills.service.ts +7 -7
- package/src/modules/skills/skills.contract.ts +4 -4
- package/src/modules/skills/skills.service.ts +5 -5
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
- 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 +27 -27
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
- package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
- package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
- package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
- package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
- package/src/modules/workflow/git/git.service.ts +2 -2
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/workflow-hooks.ts +101 -101
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
- package/src/modules/workspace/__tests__/workspace.service.test.ts +27 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/session-sink.ts +25 -25
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +495 -0
- package/src/modules/workspace/startup/kb-git.ts +94 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +597 -0
- package/src/modules/workspace/startup/on-server-start.ts +97 -0
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +636 -0
- package/src/modules/workspace/startup/steps/groups-to-plugins.step.ts +561 -0
- package/src/modules/workspace/startup/steps/roles-yaml.step.ts +71 -0
- package/src/modules/workspace/startup/steps/seed-tree.ts +115 -0
- package/src/modules/workspace/startup/steps/template-files.step.ts +360 -0
- package/src/modules/workspace/workspace.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +2 -35
- package/src/modules/workspace/workspace.tools.ts +922 -922
- package/src/shared/__tests__/join-request.test.ts +13 -13
- package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +3 -3
- package/src/shared/utcp-namespace.ts +10 -68
- package/src/shared/variable-refs.ts +64 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
- package/src/modules/groups/index.ts +0 -17
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +0 -444
- package/src/modules/workspace/kb-seed.interface.ts +0 -36
- package/src/modules/workspace/kb-seed.service.ts +0 -555
- package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { KbStartupRunner } from '../../kb-startup-runner.js';
|
|
8
|
+
import type { OnServerStart, ServerStartContext, StepResult } from '../../on-server-start.js';
|
|
9
|
+
import { GroupsToPluginsStep } from '../groups-to-plugins.step.js';
|
|
10
|
+
import { RolesYamlStep } from '../roles-yaml.step.js';
|
|
11
|
+
import { renderRolesYaml } from '../../../../access/render-roles-yaml.js';
|
|
12
|
+
import { TemplateFilesStep } from '../template-files.step.js';
|
|
13
|
+
import { buildSeedTree } from '../seed-tree.js';
|
|
14
|
+
import { defaultKbTemplateDir } from '../../../../../assets.js';
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Integration tests for the three core steps THROUGH the real runner — the
|
|
20
|
+
* ops they declare only matter as the tree the runner commits and pushes.
|
|
21
|
+
* The deep migration edge cases (ported from the deleted in-place module's
|
|
22
|
+
* suite) live in the "migration edge cases" describe below.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The real seed template shipped inside this package (see assets.ts). */
|
|
26
|
+
const TEMPLATE_DIR = defaultKbTemplateDir();
|
|
27
|
+
|
|
28
|
+
const PROTECTED = ['current-company-state', 'target-company-state'];
|
|
29
|
+
const DEFAULT_BRANCH = 'current-company-state';
|
|
30
|
+
|
|
31
|
+
async function git(cwd: string, args: string[]): Promise<string> {
|
|
32
|
+
const { stdout } = await execFileAsync('git', args, {
|
|
33
|
+
cwd,
|
|
34
|
+
env: {
|
|
35
|
+
...process.env,
|
|
36
|
+
GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 't@x.com',
|
|
37
|
+
GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 't@x.com',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
return stdout.toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let root: string;
|
|
44
|
+
let upstream: string;
|
|
45
|
+
let workspacesRoot: string;
|
|
46
|
+
|
|
47
|
+
beforeEach(async () => {
|
|
48
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'kb-steps-'));
|
|
49
|
+
workspacesRoot = path.join(root, 'workspaces');
|
|
50
|
+
await fs.mkdir(workspacesRoot, { recursive: true });
|
|
51
|
+
upstream = path.join(root, 'upstream.git');
|
|
52
|
+
await git(root, ['init', '--bare', '-b', DEFAULT_BRANCH, upstream]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
vi.restoreAllMocks();
|
|
57
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** A populated upstream carrying `files`: one commit, both protected refs. Returns the seed clone. */
|
|
61
|
+
async function seedUpstream(files: Record<string, string>): Promise<string> {
|
|
62
|
+
const seed = path.join(root, '.seed');
|
|
63
|
+
await fs.mkdir(seed, { recursive: true });
|
|
64
|
+
await git(seed, ['init', '-b', DEFAULT_BRANCH]);
|
|
65
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
66
|
+
const abs = path.join(seed, rel);
|
|
67
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
68
|
+
await fs.writeFile(abs, content, 'utf8');
|
|
69
|
+
}
|
|
70
|
+
await git(seed, ['add', '-A']);
|
|
71
|
+
await git(seed, ['commit', '-m', 'init']);
|
|
72
|
+
await git(seed, ['branch', PROTECTED[1]!]);
|
|
73
|
+
await git(seed, ['remote', 'add', 'origin', upstream]);
|
|
74
|
+
await git(seed, ['push', 'origin', ...PROTECTED]);
|
|
75
|
+
return seed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeRunner(steps: OnServerStart[], templateDir: string = TEMPLATE_DIR) {
|
|
79
|
+
return new KbStartupRunner({
|
|
80
|
+
kbRepoUrl: () => upstream,
|
|
81
|
+
gitUsername: () => 'x-access-token',
|
|
82
|
+
workspacesRoot,
|
|
83
|
+
kbDirName: 'knowledge-base',
|
|
84
|
+
templateDir,
|
|
85
|
+
defaultBranch: () => DEFAULT_BRANCH,
|
|
86
|
+
protectedBranches: () => PROTECTED,
|
|
87
|
+
seedAdminEmails: ['admin@example.com'],
|
|
88
|
+
steps,
|
|
89
|
+
buildSeedTree: async (dir: string) => {
|
|
90
|
+
await fs.writeFile(path.join(dir, 'seeded.md'), 'from template', 'utf8');
|
|
91
|
+
return [];
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function step(name: string, run: (ctx: ServerStartContext) => Promise<StepResult>): OnServerStart {
|
|
97
|
+
return { name, run };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function checkout(branch: string): Promise<string> {
|
|
101
|
+
const dir = path.join(root, `checkout-${branch.replace(/\W/g, '-')}-${Math.random().toString(36).slice(2)}`);
|
|
102
|
+
await git(root, ['clone', '-b', branch, upstream, dir]);
|
|
103
|
+
return dir;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function exists(dir: string, rel: string): Promise<boolean> {
|
|
107
|
+
return fs.access(path.join(dir, rel)).then(() => true, () => false);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const norm = (text: string) => text.replace(/\r\n?/g, '\n');
|
|
111
|
+
|
|
112
|
+
async function template(name: string): Promise<string> {
|
|
113
|
+
return fs.readFile(path.join(TEMPLATE_DIR, name), 'utf8');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Every required file + reserved root already present, from the real template. */
|
|
117
|
+
async function fullScaffold(): Promise<Record<string, string>> {
|
|
118
|
+
return {
|
|
119
|
+
'access.md': await template('access.md'),
|
|
120
|
+
'AGENTS.md': await template('AGENTS.md'),
|
|
121
|
+
'.bevelignore': await template('.bevelignore'),
|
|
122
|
+
'.gitignore': await template('gitignore.template'),
|
|
123
|
+
'KnowledgeBase/.gitkeep': '',
|
|
124
|
+
'Plugins/.gitkeep': '',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe('TemplateFilesStep', () => {
|
|
129
|
+
it('adds the missing scaffolding — .gitignore arriving from its packable template spelling', async () => {
|
|
130
|
+
await seedUpstream({ 'marker.txt': 'seeded' });
|
|
131
|
+
await makeRunner([new TemplateFilesStep()]).runAll();
|
|
132
|
+
|
|
133
|
+
for (const b of PROTECTED) {
|
|
134
|
+
const dir = await checkout(b);
|
|
135
|
+
for (const rel of ['access.md', 'AGENTS.md', '.bevelignore', '.gitignore']) {
|
|
136
|
+
expect(await exists(dir, rel), `${b}: ${rel}`).toBe(true);
|
|
137
|
+
}
|
|
138
|
+
// The packaged template cannot ship a literal .gitignore (npm strips
|
|
139
|
+
// them); the step must have read gitignore.template and written the
|
|
140
|
+
// real name.
|
|
141
|
+
expect(norm(await fs.readFile(path.join(dir, '.gitignore'), 'utf8'))).toBe(
|
|
142
|
+
norm(await template('gitignore.template')),
|
|
143
|
+
);
|
|
144
|
+
// Reserved roots materialize as <dir>/.gitkeep.
|
|
145
|
+
expect(await exists(dir, 'KnowledgeBase/.gitkeep')).toBe(true);
|
|
146
|
+
expect(await exists(dir, 'Plugins/.gitkeep')).toBe(true);
|
|
147
|
+
const subject = (await git(dir, ['log', '--format=%s', '-1'])).trim();
|
|
148
|
+
expect(subject).toMatch(/^Add missing KB scaffolding: /);
|
|
149
|
+
expect(subject).toContain('.gitignore');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('replaces a drifted AGENTS.md, and says so when that is the only change', async () => {
|
|
154
|
+
await seedUpstream({ ...(await fullScaffold()), 'AGENTS.md': 'stale conventions\n' });
|
|
155
|
+
await makeRunner([new TemplateFilesStep()]).runAll();
|
|
156
|
+
|
|
157
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
158
|
+
expect(norm(await fs.readFile(path.join(dir, 'AGENTS.md'), 'utf8'))).toBe(norm(await template('AGENTS.md')));
|
|
159
|
+
const subject = (await git(dir, ['log', '--format=%s', '-1'])).trim();
|
|
160
|
+
expect(subject).toBe('Update AGENTS.md to the current platform template');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects .git — any case — as a reserved root name', async () => {
|
|
164
|
+
for (const bad of ['.git', '.GIT', '.Git']) {
|
|
165
|
+
expect(() => new TemplateFilesStep([bad]), bad).toThrow(/must not be "\.git"/);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('appends the AGENTS.md rule to a custom template\'s .bevelignore that lacks it, at declaration time', async () => {
|
|
170
|
+
// A distribution's own template whose ignore file does not carry the rule
|
|
171
|
+
// the on-disk merge assumes: the merge only runs against an EXISTING file,
|
|
172
|
+
// so the declared content itself must arrive with the rule in it.
|
|
173
|
+
const customTemplate = path.join(root, 'custom-template');
|
|
174
|
+
await fs.mkdir(customTemplate, { recursive: true });
|
|
175
|
+
await fs.writeFile(path.join(customTemplate, 'AGENTS.md'), await template('AGENTS.md'), 'utf8');
|
|
176
|
+
await fs.writeFile(path.join(customTemplate, '.bevelignore'), '# custom\nMyStuff/\n', 'utf8');
|
|
177
|
+
const scaffold = await fullScaffold();
|
|
178
|
+
delete scaffold['.bevelignore']; // the one file the step will declare from the template
|
|
179
|
+
await seedUpstream(scaffold);
|
|
180
|
+
|
|
181
|
+
await makeRunner([new TemplateFilesStep()], customTemplate).runAll();
|
|
182
|
+
|
|
183
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
184
|
+
const lines = norm(await fs.readFile(path.join(dir, '.bevelignore'), 'utf8')).split('\n').map((l) => l.trim());
|
|
185
|
+
expect(lines).toContain('MyStuff/'); // the operator's rules survive
|
|
186
|
+
expect(lines).toContain('AGENTS.md'); // the platform's rule was appended
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('respects an explicit !AGENTS.md negation — hiding the doc is a default, not a mandate', async () => {
|
|
190
|
+
// Appending the positive rule after the negation would WIN under ordered
|
|
191
|
+
// matching and silently defeat the operator's stated choice to show it.
|
|
192
|
+
const scaffold = await fullScaffold();
|
|
193
|
+
scaffold['.bevelignore'] = '# operator wants the doc visible\n!AGENTS.md\n';
|
|
194
|
+
await seedUpstream(scaffold);
|
|
195
|
+
|
|
196
|
+
await makeRunner([new TemplateFilesStep()]).runAll();
|
|
197
|
+
|
|
198
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
199
|
+
expect(norm(await fs.readFile(path.join(dir, '.bevelignore'), 'utf8'))).toBe(
|
|
200
|
+
'# operator wants the doc visible\n!AGENTS.md\n',
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('treats a CRLF checkout of identical AGENTS.md content as current — no churn commit', async () => {
|
|
205
|
+
const scaffold = await fullScaffold();
|
|
206
|
+
scaffold['AGENTS.md'] = norm(scaffold['AGENTS.md']!).replace(/\n/g, '\r\n');
|
|
207
|
+
await seedUpstream(scaffold);
|
|
208
|
+
await makeRunner([new TemplateFilesStep()]).runAll();
|
|
209
|
+
|
|
210
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
211
|
+
expect((await git(dir, ['rev-list', '--count', 'HEAD'])).trim()).toBe('1'); // init only
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('RolesYamlStep', () => {
|
|
216
|
+
it('generates roles.yaml with the configured admins on every protected branch', async () => {
|
|
217
|
+
await seedUpstream({ 'marker.txt': 'seeded' });
|
|
218
|
+
await makeRunner([new RolesYamlStep(['admin@example.com'])]).runAll();
|
|
219
|
+
|
|
220
|
+
for (const b of PROTECTED) {
|
|
221
|
+
const dir = await checkout(b);
|
|
222
|
+
// norm: a Windows checkout may hand the file back CRLF.
|
|
223
|
+
const roles = norm(await fs.readFile(path.join(dir, 'roles.yaml'), 'utf8'));
|
|
224
|
+
expect(roles).toContain('# Identity → role mapping for access control.');
|
|
225
|
+
expect(roles).toContain(' Admin:\n - admin@example.com');
|
|
226
|
+
const subject = (await git(dir, ['log', '--format=%s', '-1'])).trim();
|
|
227
|
+
expect(subject).toBe('Add roles.yaml granting Admin to the configured seed admins');
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('throws — naming the branch and path — when a directory squats the roles.yaml name', async () => {
|
|
232
|
+
// A dir (or symlink) named roles.yaml would read as "present" to a
|
|
233
|
+
// skip-if-present check, reporting success over a KB whose access roster
|
|
234
|
+
// cannot be read. Fail closed instead.
|
|
235
|
+
await seedUpstream({ 'roles.yaml/placeholder.txt': 'squatter' });
|
|
236
|
+
await expect(makeRunner([new RolesYamlStep(['admin@example.com'])]).runAll()).rejects.toThrow(
|
|
237
|
+
/"roles\.yaml" on branch "current-company-state" exists but is not a regular file \(directory\)/,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('declares a skip when the file is missing and no admins are configured', async () => {
|
|
242
|
+
await seedUpstream({ 'marker.txt': 'seeded' });
|
|
243
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
244
|
+
await makeRunner([new RolesYamlStep([])]).runAll(); // resolves — a declared skip, not a failure
|
|
245
|
+
|
|
246
|
+
expect(warn.mock.calls.some((c) => String(c[0]).includes('roles-yaml: skipped'))).toBe(true);
|
|
247
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
248
|
+
expect(await exists(dir, 'roles.yaml')).toBe(false);
|
|
249
|
+
expect((await git(dir, ['rev-list', '--count', 'HEAD'])).trim()).toBe('1');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('renderRolesYaml', () => {
|
|
254
|
+
it('renders trimmed admin emails', () => {
|
|
255
|
+
const yaml = renderRolesYaml([' admin@example.com ']);
|
|
256
|
+
expect(yaml).toContain(' Admin:\n - admin@example.com\n');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('throws on an email that would corrupt roles.yaml instead of rendering an adminless file', () => {
|
|
260
|
+
// Empty after trim, a YAML comment, embedded whitespace/newline — each
|
|
261
|
+
// would render a file with no working Admin, silently.
|
|
262
|
+
for (const bad of ['', ' ', '#admin@example.com', 'admin@example.com\nextra', 'a b@example.com']) {
|
|
263
|
+
expect(() => renderRolesYaml([bad]), JSON.stringify(bad)).toThrow(/admin email/i);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('throws on anything the real access parser would reject, via parse-back', () => {
|
|
268
|
+
// Passes the character-level checks above but fails the parser's email
|
|
269
|
+
// grammar — e.g. an address-book "Name <email>" shape or a non-email.
|
|
270
|
+
for (const bad of ['<admin@example.com>', 'not-an-email']) {
|
|
271
|
+
expect(() => renderRolesYaml([bad]), JSON.stringify(bad)).toThrow(/would not parse/i);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('buildSeedTree', () => {
|
|
277
|
+
it('skips .git at any depth of the template walk, and names the paths it generated', async () => {
|
|
278
|
+
const templateDir = path.join(root, 'seed-template');
|
|
279
|
+
// A KB_TEMPLATE_DIR that is itself a git working tree: .git at the root
|
|
280
|
+
// and (pathologically) nested must never be seeded into the KB.
|
|
281
|
+
await fs.mkdir(path.join(templateDir, '.git'), { recursive: true });
|
|
282
|
+
await fs.writeFile(path.join(templateDir, '.git', 'config'), '[core]', 'utf8');
|
|
283
|
+
await fs.mkdir(path.join(templateDir, 'docs', '.git'), { recursive: true });
|
|
284
|
+
await fs.writeFile(path.join(templateDir, 'docs', '.git', 'HEAD'), 'ref:', 'utf8');
|
|
285
|
+
await fs.writeFile(path.join(templateDir, 'docs', 'guide.md'), 'guide', 'utf8');
|
|
286
|
+
await fs.writeFile(path.join(templateDir, 'access.md'), 'policy', 'utf8');
|
|
287
|
+
|
|
288
|
+
const dest = path.join(root, 'seed-dest');
|
|
289
|
+
await fs.mkdir(dest, { recursive: true });
|
|
290
|
+
const generated = await buildSeedTree(templateDir, [], ['admin@example.com'])(dest);
|
|
291
|
+
|
|
292
|
+
expect(await exists(dest, '.git')).toBe(false);
|
|
293
|
+
expect(await exists(dest, 'docs/.git')).toBe(false);
|
|
294
|
+
expect(await fs.readFile(path.join(dest, 'docs/guide.md'), 'utf8')).toBe('guide');
|
|
295
|
+
expect(await fs.readFile(path.join(dest, 'access.md'), 'utf8')).toBe('policy');
|
|
296
|
+
// The generated paths — what the runner force-adds past a template .gitignore.
|
|
297
|
+
expect(generated.sort()).toEqual(['KnowledgeBase/.gitkeep', 'Plugins/.gitkeep', 'roles.yaml']);
|
|
298
|
+
expect(await exists(dest, 'roles.yaml')).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('GroupsToPluginsStep', () => {
|
|
303
|
+
it('migrates a Groups/ tree to the Plugins layout — on the protected branches AND a draft', async () => {
|
|
304
|
+
const seed = await seedUpstream({
|
|
305
|
+
'marker.txt': 'seeded',
|
|
306
|
+
'.bevelignore': 'Groups/\n',
|
|
307
|
+
'Groups/GTM/access.md': '---\nread:\n - everyone\n---\n',
|
|
308
|
+
'Groups/GTM/outreach/SKILL.md': '---\ndescription: Outreach.\n---\n# Outreach\n',
|
|
309
|
+
'Groups/GTM/web-search.tool': JSON.stringify(
|
|
310
|
+
{ name: 'web_search', type: 'http', url: 'https://search.example/api' },
|
|
311
|
+
null,
|
|
312
|
+
2,
|
|
313
|
+
),
|
|
314
|
+
'Groups/GTM/notion.tool': JSON.stringify(
|
|
315
|
+
{ name: 'notion', type: 'mcp', url: 'https://mcp.notion.com/mcp' },
|
|
316
|
+
null,
|
|
317
|
+
2,
|
|
318
|
+
),
|
|
319
|
+
});
|
|
320
|
+
// A draft carrying the same pre-migration tree: allBranches scope means it
|
|
321
|
+
// migrates alongside its target, keeping its CR diff to the user's changes.
|
|
322
|
+
await git(seed, ['checkout', '-b', 'alice/draft']);
|
|
323
|
+
await git(seed, ['push', 'origin', 'alice/draft']);
|
|
324
|
+
|
|
325
|
+
await makeRunner([new GroupsToPluginsStep()]).runAll();
|
|
326
|
+
|
|
327
|
+
for (const b of [DEFAULT_BRANCH, 'alice/draft']) {
|
|
328
|
+
const dir = await checkout(b);
|
|
329
|
+
expect(await exists(dir, 'Groups'), `${b}: Groups/ gone`).toBe(false);
|
|
330
|
+
expect(await exists(dir, 'Plugins/GTM/access.md')).toBe(true);
|
|
331
|
+
expect(await exists(dir, 'Plugins/GTM/skills/outreach/SKILL.md')).toBe(true);
|
|
332
|
+
// http manual MOVED as a .tool; mcp manual CONVERTED and its source deleted.
|
|
333
|
+
expect(await exists(dir, 'Plugins/GTM/software.bevel.hexis/tools/web-search.tool')).toBe(true);
|
|
334
|
+
expect(await exists(dir, 'Plugins/GTM/notion.tool')).toBe(false);
|
|
335
|
+
expect(await exists(dir, 'Plugins/GTM/software.bevel.hexis/tools/notion.tool')).toBe(false);
|
|
336
|
+
const mcp = JSON.parse(await fs.readFile(path.join(dir, 'Plugins/GTM/mcp.json'), 'utf8'));
|
|
337
|
+
expect(mcp.mcpServers.notion).toEqual({ type: 'streamable-http', url: 'https://mcp.notion.com/mcp' });
|
|
338
|
+
const manifest = JSON.parse(await fs.readFile(path.join(dir, 'Plugins/GTM/plugin.json'), 'utf8'));
|
|
339
|
+
expect(manifest.name).toBe('gtm');
|
|
340
|
+
// The rename's companion edit: the stale ignore rule now names Plugins/.
|
|
341
|
+
const ignore = await fs.readFile(path.join(dir, '.bevelignore'), 'utf8');
|
|
342
|
+
expect(ignore).toContain('Plugins/');
|
|
343
|
+
expect(ignore).not.toContain('Groups/');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
347
|
+
const log = await git(dir, ['log', '--format=%s%n%b', '-1']);
|
|
348
|
+
expect(log).toContain('Move Groups/ to Plugins/ (Agent Plugins layout)'); // the subject
|
|
349
|
+
expect(log).toContain('Groups/ → Plugins/');
|
|
350
|
+
expect(log).toContain('notion.tool converted to an mcp.json entry');
|
|
351
|
+
expect(log).toContain('web-search.tool → software.bevel.hexis/tools/web-search.tool');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('throws on a file squatting the Plugins name even when no Groups/ exists', async () => {
|
|
355
|
+
// Without Groups/ the step used to early-return before the squat guard —
|
|
356
|
+
// silently skipping a branch whose reserved root cannot be a plugin tree
|
|
357
|
+
// (and on a draft nothing later would ever report it).
|
|
358
|
+
await seedUpstream({ Plugins: 'i am a file, not a folder' });
|
|
359
|
+
await expect(makeRunner([new GroupsToPluginsStep()]).runAll()).rejects.toThrow(
|
|
360
|
+
/"Plugins" exists but is not a directory/,
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('refuses a branch carrying BOTH roots — nothing moves, and the note names the state', async () => {
|
|
365
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
366
|
+
await seedUpstream({
|
|
367
|
+
'Groups/A/x.md': 'legacy',
|
|
368
|
+
'Plugins/B/y.md': 'new',
|
|
369
|
+
});
|
|
370
|
+
await makeRunner([
|
|
371
|
+
new GroupsToPluginsStep(),
|
|
372
|
+
// A later step dirties the branch so the refusal note surfaces in the commit.
|
|
373
|
+
step('dirty', async (ctx) => {
|
|
374
|
+
for (const b of await ctx.protectedBranches()) {
|
|
375
|
+
b.write('z.md', 'z');
|
|
376
|
+
b.note('Add z.md');
|
|
377
|
+
}
|
|
378
|
+
return { outcome: 'ok' };
|
|
379
|
+
}),
|
|
380
|
+
]).runAll();
|
|
381
|
+
|
|
382
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
383
|
+
// Both trees untouched — the migration guessed at nothing.
|
|
384
|
+
expect(await fs.readFile(path.join(dir, 'Groups/A/x.md'), 'utf8')).toBe('legacy');
|
|
385
|
+
expect(await fs.readFile(path.join(dir, 'Plugins/B/y.md'), 'utf8')).toBe('new');
|
|
386
|
+
expect(await exists(dir, 'Plugins/B/plugin.json')).toBe(false);
|
|
387
|
+
const log = await git(dir, ['log', '--format=%s%n%b', '-1']);
|
|
388
|
+
expect(log).toContain('Groups/ and Plugins/ both exist — merge by hand');
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Migration edge cases ported from the deleted in-place module's suite
|
|
394
|
+
* (plugins-migration.test.ts) — same assertions, new trigger: the step runs
|
|
395
|
+
* through the real runner and the tree under test is a fresh checkout of what
|
|
396
|
+
* it committed. Refusals now surface through the step's `partial` outcome,
|
|
397
|
+
* which the runner logs via console.warn.
|
|
398
|
+
*/
|
|
399
|
+
describe('GroupsToPluginsStep — migration edge cases', () => {
|
|
400
|
+
async function migrate(): Promise<void> {
|
|
401
|
+
await makeRunner([new GroupsToPluginsStep()]).runAll();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function readJson(dir: string, rel: string): Promise<Record<string, any>> {
|
|
405
|
+
return JSON.parse(await fs.readFile(path.join(dir, rel), 'utf8'));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** What the runner's `partial` warning carried — the named refusals. */
|
|
409
|
+
function partialReason(warn: { mock: { calls: unknown[][] } }): string {
|
|
410
|
+
return warn.mock.calls
|
|
411
|
+
.flat()
|
|
412
|
+
.map(String)
|
|
413
|
+
.filter((line) => line.includes('groups-to-plugins: partial'))
|
|
414
|
+
.join(' ');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
it('merges into an existing mcp.json without clobbering what is already there', async () => {
|
|
418
|
+
await seedUpstream({
|
|
419
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
420
|
+
'Groups/GTM/notion.tool': JSON.stringify(
|
|
421
|
+
{ name: 'notion', type: 'mcp', url: 'https://mcp.notion.com/mcp' },
|
|
422
|
+
null,
|
|
423
|
+
2,
|
|
424
|
+
),
|
|
425
|
+
'Groups/GTM/mcp.json': JSON.stringify({
|
|
426
|
+
mcpServers: { notion: { type: 'streamable-http', url: 'https://hand.example/mcp' } },
|
|
427
|
+
}),
|
|
428
|
+
});
|
|
429
|
+
await migrate();
|
|
430
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
431
|
+
const mcp = await readJson(dir, 'Plugins/GTM/mcp.json');
|
|
432
|
+
// The hand-written notion entry WINS; the converted .tool is gone either way.
|
|
433
|
+
expect(mcp.mcpServers.notion.url).toBe('https://hand.example/mcp');
|
|
434
|
+
expect(await exists(dir, 'Plugins/GTM/notion.tool')).toBe(false);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('refuses to convert an mcp .tool whose url is not directly parseable http(s)', async () => {
|
|
438
|
+
// A templated url (`${VENDOR_BASE}/mcp`) is legal in a `.tool`, where the
|
|
439
|
+
// substitutor expands it — but the mcp.json loader validates `new URL`
|
|
440
|
+
// and skips the entry, so converting would delete the source and write a
|
|
441
|
+
// dead entry: the integration would simply vanish.
|
|
442
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
443
|
+
await seedUpstream({
|
|
444
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
445
|
+
'Groups/GTM/vendor.tool': JSON.stringify({ name: 'vendor', type: 'mcp', url: '${VENDOR_BASE}/mcp' }),
|
|
446
|
+
'Groups/GTM/socket.tool': JSON.stringify({ name: 'socket', type: 'mcp', url: 'wss://mcp.vendor.example/mcp' }),
|
|
447
|
+
});
|
|
448
|
+
await migrate();
|
|
449
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
450
|
+
// Both stay `.tool` files (moved with the other unconvertibles), and no
|
|
451
|
+
// mcp.json is invented for them.
|
|
452
|
+
expect(await exists(dir, 'Plugins/GTM/software.bevel.hexis/tools/vendor.tool')).toBe(true);
|
|
453
|
+
expect(await exists(dir, 'Plugins/GTM/software.bevel.hexis/tools/socket.tool')).toBe(true);
|
|
454
|
+
expect(await exists(dir, 'Plugins/GTM/mcp.json')).toBe(false);
|
|
455
|
+
// The refusal is NAMED in the partial reason — an operator must be able to
|
|
456
|
+
// tell a deliberately-retained integration from one that silently failed.
|
|
457
|
+
// (socket.tool never reaches the named refusals: its wss url fails
|
|
458
|
+
// `.tool` normalization itself, the not-a-candidate path.)
|
|
459
|
+
expect(partialReason(warn)).toMatch(/vendor\.tool NOT converted — its url is not directly parseable/);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('refuses to convert an mcp .tool whose url carries userinfo', async () => {
|
|
463
|
+
// `https://user:pass@…` copied into mcp.json would put a credential in
|
|
464
|
+
// the PORTABLE file — the exact thing the header split exists to prevent
|
|
465
|
+
// — and stripping it would break the server. The manual keeps its
|
|
466
|
+
// `.tool` form, where the credential stays platform-internal.
|
|
467
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
468
|
+
await seedUpstream({
|
|
469
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
470
|
+
'Groups/GTM/vendor.tool': JSON.stringify(
|
|
471
|
+
{ name: 'vendor', type: 'mcp', url: 'https://user:pass@mcp.vendor.example/mcp' },
|
|
472
|
+
),
|
|
473
|
+
});
|
|
474
|
+
await migrate();
|
|
475
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
476
|
+
expect(await exists(dir, 'Plugins/GTM/software.bevel.hexis/tools/vendor.tool')).toBe(true);
|
|
477
|
+
expect(await exists(dir, 'Plugins/GTM/mcp.json')).toBe(false);
|
|
478
|
+
expect(partialReason(warn)).toMatch(/vendor\.tool NOT converted — its url embeds credentials/);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('refuses to convert an mcp .tool that gates itself with frontmatter access verbs', async () => {
|
|
482
|
+
// The access resolver reads a `.tool`'s own verbs from the file itself;
|
|
483
|
+
// an mcp.json entry has no per-server home for them, so converting would
|
|
484
|
+
// silently widen who may configure and run the server.
|
|
485
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
486
|
+
await seedUpstream({
|
|
487
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
488
|
+
'Groups/GTM/gated.tool':
|
|
489
|
+
'---\nname: gated\ntype: mcp\nurl: https://mcp.vendor.example/mcp\nwrite:\n - Product Team\n---\n',
|
|
490
|
+
});
|
|
491
|
+
await migrate();
|
|
492
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
493
|
+
expect(await exists(dir, 'Plugins/GTM/software.bevel.hexis/tools/gated.tool')).toBe(true);
|
|
494
|
+
expect(await exists(dir, 'Plugins/GTM/mcp.json')).toBe(false);
|
|
495
|
+
// The verbs travel with the file — the moved copy still declares them.
|
|
496
|
+
expect(
|
|
497
|
+
await fs.readFile(path.join(dir, 'Plugins/GTM/software.bevel.hexis/tools/gated.tool'), 'utf8'),
|
|
498
|
+
).toContain('Product Team');
|
|
499
|
+
expect(partialReason(warn)).toMatch(/gated\.tool NOT converted — it gates itself with frontmatter access verbs/);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('refuses to convert an mcp .tool whose id is not a valid mcp.json server name', async () => {
|
|
503
|
+
// The mcp.json loader accepts only names it can serve as a namespace and
|
|
504
|
+
// route slug — converting would DELETE a working integration and write an
|
|
505
|
+
// entry discovery then skips.
|
|
506
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
507
|
+
await seedUpstream({
|
|
508
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
509
|
+
'Groups/GTM/vendor.tool': JSON.stringify(
|
|
510
|
+
{ name: 'MyVendor', type: 'mcp', url: 'https://mcp.vendor.example/mcp' },
|
|
511
|
+
),
|
|
512
|
+
});
|
|
513
|
+
await migrate();
|
|
514
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
515
|
+
expect(await exists(dir, 'Plugins/GTM/software.bevel.hexis/tools/vendor.tool')).toBe(true);
|
|
516
|
+
expect(await exists(dir, 'Plugins/GTM/mcp.json')).toBe(false);
|
|
517
|
+
expect(partialReason(warn)).toMatch(/vendor\.tool NOT converted — its id "MyVendor" is not a valid mcp\.json server name/);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('splits headers: literals into mcp.json, credential references into plugin.json extensions', async () => {
|
|
521
|
+
await seedUpstream({
|
|
522
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
523
|
+
'Groups/GTM/vendor.tool': JSON.stringify({
|
|
524
|
+
name: 'vendor',
|
|
525
|
+
type: 'mcp',
|
|
526
|
+
url: 'https://mcp.vendor.example/mcp',
|
|
527
|
+
headers: { Authorization: 'Bearer ${VENDOR_KEY}', 'X-Api-Version': '2' },
|
|
528
|
+
}),
|
|
529
|
+
});
|
|
530
|
+
await migrate();
|
|
531
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
532
|
+
const mcp = await readJson(dir, 'Plugins/GTM/mcp.json');
|
|
533
|
+
// The spec forbids credentials in `headers` and forbids expanding anything
|
|
534
|
+
// but ${PLUGIN_ROOT}/${PLUGIN_DATA} — a copied ${VENDOR_KEY} would be sent
|
|
535
|
+
// literally by a conformant client.
|
|
536
|
+
expect(mcp.mcpServers.vendor.headers).toEqual({ 'X-Api-Version': '2' });
|
|
537
|
+
// The reference lives on in the extensions block, which is ours to interpret.
|
|
538
|
+
const manifest = await readJson(dir, 'Plugins/GTM/plugin.json');
|
|
539
|
+
expect(manifest.extensions['software.bevel.hexis'].mcpServers.vendor.headers).toEqual({
|
|
540
|
+
Authorization: 'Bearer ${VENDOR_KEY}',
|
|
541
|
+
});
|
|
542
|
+
expect(await exists(dir, 'Plugins/GTM/software.bevel.hexis/tools/vendor.tool')).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it('routes anything the substitutor would expand to the extensions block — non-name ${…} stays literal', async () => {
|
|
546
|
+
await seedUpstream({
|
|
547
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
548
|
+
'Groups/GTM/vendor.tool': JSON.stringify({
|
|
549
|
+
name: 'vendor',
|
|
550
|
+
type: 'mcp',
|
|
551
|
+
url: 'https://mcp.vendor.example/mcp',
|
|
552
|
+
// The substitutor's grammar decides: bare `$VENDOR_KEY` and the
|
|
553
|
+
// digit-leading `$5` in the price BOTH expand, so both route to the
|
|
554
|
+
// non-portable half; `${not-a-name}` is not expandable and stays.
|
|
555
|
+
headers: {
|
|
556
|
+
Authorization: 'Bearer $VENDOR_KEY',
|
|
557
|
+
'X-Price': '$5 per call',
|
|
558
|
+
'X-Tag': 'v ${not-a-name}',
|
|
559
|
+
},
|
|
560
|
+
}),
|
|
561
|
+
});
|
|
562
|
+
await migrate();
|
|
563
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
564
|
+
const mcp = await readJson(dir, 'Plugins/GTM/mcp.json');
|
|
565
|
+
expect(mcp.mcpServers.vendor.headers).toEqual({ 'X-Tag': 'v ${not-a-name}' });
|
|
566
|
+
const manifest = await readJson(dir, 'Plugins/GTM/plugin.json');
|
|
567
|
+
expect(manifest.extensions['software.bevel.hexis'].mcpServers.vendor.headers).toEqual({
|
|
568
|
+
Authorization: 'Bearer $VENDOR_KEY',
|
|
569
|
+
'X-Price': '$5 per call',
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('leaves a personal folder a valid plugin', async () => {
|
|
574
|
+
await seedUpstream({
|
|
575
|
+
'Groups/personal-u-123/access.md': 'write:\n - Ali <ali@x.com>\n',
|
|
576
|
+
});
|
|
577
|
+
await migrate();
|
|
578
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
579
|
+
const manifest = await readJson(dir, 'Plugins/personal-u-123/plugin.json');
|
|
580
|
+
expect(manifest.name).toMatch(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
describe('the .bevelignore root rule', () => {
|
|
584
|
+
it('follows the rename, preserving every other line', async () => {
|
|
585
|
+
await seedUpstream({
|
|
586
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
587
|
+
'.bevelignore': '# mine\nGroups/\nMy-Own-Rule/\n',
|
|
588
|
+
});
|
|
589
|
+
await migrate();
|
|
590
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
591
|
+
const ignore = norm(await fs.readFile(path.join(dir, '.bevelignore'), 'utf8'));
|
|
592
|
+
expect(ignore.split('\n')).toContain('Plugins/');
|
|
593
|
+
expect(ignore).not.toContain('Groups/');
|
|
594
|
+
expect(ignore).toContain('# mine');
|
|
595
|
+
expect(ignore).toContain('My-Own-Rule/');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('rewrites EVERY exact Groups/ line — the first becomes Plugins/, duplicates are dropped', async () => {
|
|
599
|
+
await seedUpstream({
|
|
600
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
601
|
+
'.bevelignore': 'Groups/\n# keep\nGroups/\n',
|
|
602
|
+
});
|
|
603
|
+
await migrate();
|
|
604
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
605
|
+
const lines = norm(await fs.readFile(path.join(dir, '.bevelignore'), 'utf8')).split('\n');
|
|
606
|
+
expect(lines.filter((l) => l.trim() === 'Plugins/')).toHaveLength(1);
|
|
607
|
+
expect(lines.filter((l) => l.trim() === 'Groups/')).toHaveLength(0);
|
|
608
|
+
expect(lines).toContain('# keep');
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('is left alone when Plugins/ is already listed', async () => {
|
|
612
|
+
await seedUpstream({
|
|
613
|
+
'Groups/GTM/access.md': 'write:\n - Admin\n',
|
|
614
|
+
'.bevelignore': 'Groups/\nPlugins/\n',
|
|
615
|
+
});
|
|
616
|
+
await migrate();
|
|
617
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
618
|
+
// The stale line is harmlessly dead; deleting it would be editing the
|
|
619
|
+
// operator's file beyond what the rename made stale.
|
|
620
|
+
expect(norm(await fs.readFile(path.join(dir, '.bevelignore'), 'utf8'))).toBe('Groups/\nPlugins/\n');
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('is not touched by a run that does not rename', async () => {
|
|
624
|
+
await seedUpstream({
|
|
625
|
+
'Plugins/GTM/access.md': 'write:\n - Admin\n',
|
|
626
|
+
'Plugins/GTM/outreach/SKILL.md': '# Outreach\n',
|
|
627
|
+
'.bevelignore': 'Groups/\n',
|
|
628
|
+
});
|
|
629
|
+
await migrate();
|
|
630
|
+
const dir = await checkout(DEFAULT_BRANCH);
|
|
631
|
+
expect(norm(await fs.readFile(path.join(dir, '.bevelignore'), 'utf8'))).toBe('Groups/\n');
|
|
632
|
+
// The run still reorganised the folder — the rule alone was off-limits.
|
|
633
|
+
expect(await exists(dir, 'Plugins/GTM/skills/outreach/SKILL.md')).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
});
|