@bevel-software/platform-core-backend 0.7.4 → 0.8.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 +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +51 -7
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +5 -3
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +38 -21
- 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.js +19 -19
- 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/roles-admin.service.d.ts +18 -4
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +14 -4
- 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/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/workflow/pending-commits.service.d.ts +11 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +37 -9
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.d.ts +17 -4
- package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.worker.js +63 -9
- package/dist/modules/workflow/pending-commits.worker.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +1 -0
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +6 -1
- package/dist/modules/workflow/workflow.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/workspace.routes.js +3 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +26 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +83 -12
- 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/migrations/meta/0000_snapshot.json +1479 -1479
- package/package.json +5 -4
- package/src/assets.ts +25 -25
- package/src/core/core-ports.ts +106 -106
- package/src/core/create-core-server.ts +55 -9
- package/src/core/create-core-services.ts +48 -24
- 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 +13 -1
- 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 +20 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/roles-admin.service.ts +13 -2
- 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/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__/pending-commits.worker.test.ts +118 -0
- 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/pending-commits.service.ts +48 -12
- package/src/modules/workflow/pending-commits.worker.ts +64 -8
- 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/workflow/workflow.service.ts +13 -2
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
- package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
- 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 +116 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/kb-seed.service.ts +38 -9
- package/src/modules/workspace/plugins-migration.ts +479 -0
- package/src/modules/workspace/session-sink.ts +25 -25
- package/src/modules/workspace/workspace.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +85 -14
- 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/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
|
@@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
6
|
-
import {
|
|
6
|
+
import { PluginIndexService } from '../plugins.service.js';
|
|
7
7
|
import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
|
|
8
8
|
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
9
9
|
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
@@ -26,7 +26,7 @@ function tools(...paths: string[]): ToolManualSummary[] {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
describe('
|
|
29
|
+
describe('PluginIndexService', () => {
|
|
30
30
|
let root: string;
|
|
31
31
|
|
|
32
32
|
const workspaceService = {
|
|
@@ -52,7 +52,7 @@ describe('GroupIndexService', () => {
|
|
|
52
52
|
tools?: ToolManualSummary[];
|
|
53
53
|
workspace?: WorkspaceService;
|
|
54
54
|
} = {}) =>
|
|
55
|
-
new
|
|
55
|
+
new PluginIndexService(
|
|
56
56
|
opts.workspace ?? workspaceService,
|
|
57
57
|
opts.access ?? principals,
|
|
58
58
|
skillService(opts.skills),
|
|
@@ -62,68 +62,68 @@ describe('GroupIndexService', () => {
|
|
|
62
62
|
|
|
63
63
|
const kb = () => join(root, wsId, KB_DIR);
|
|
64
64
|
|
|
65
|
-
/** A REAL
|
|
66
|
-
const
|
|
67
|
-
await mkdir(join(kb(), '
|
|
68
|
-
await writeFile(join(kb(), '
|
|
65
|
+
/** A REAL plugin: a folder carrying the access.md that makes it exist. */
|
|
66
|
+
const pluginDir = async (name: string) => {
|
|
67
|
+
await mkdir(join(kb(), 'Plugins', name), { recursive: true });
|
|
68
|
+
await writeFile(join(kb(), 'Plugins', name, 'access.md'), '---\nread:\n - everyone\n---\n');
|
|
69
69
|
};
|
|
70
70
|
|
|
71
71
|
beforeEach(async () => {
|
|
72
|
-
root = await mkdtemp(join(tmpdir(), '
|
|
72
|
+
root = await mkdtemp(join(tmpdir(), 'plugin-index-'));
|
|
73
73
|
await mkdir(kb(), { recursive: true });
|
|
74
74
|
});
|
|
75
75
|
afterEach(() => rm(root, { recursive: true, force: true }));
|
|
76
76
|
|
|
77
|
-
test('enumerates
|
|
78
|
-
await
|
|
79
|
-
await
|
|
77
|
+
test('enumerates Plugins/ folders carrying an access.md, sorted by name', async () => {
|
|
78
|
+
await pluginDir('GTM');
|
|
79
|
+
await pluginDir('Engineering');
|
|
80
80
|
|
|
81
81
|
const catalog = await svc().catalog();
|
|
82
82
|
expect(catalog.map((g) => g.name)).toEqual(['Engineering', 'GTM']);
|
|
83
|
-
expect(catalog[1].folders).toEqual(['
|
|
83
|
+
expect(catalog[1].folders).toEqual(['Plugins/GTM']);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test('a folder without an access.md is not a
|
|
87
|
-
// The residue a deleted
|
|
86
|
+
test('a folder without an access.md is not a plugin', async () => {
|
|
87
|
+
// The residue a deleted plugin leaves behind: git cannot record an empty
|
|
88
88
|
// directory, so the folder outlives its files on live checkouts — and a
|
|
89
|
-
// folder with content but no access.md is just as much a non-
|
|
90
|
-
await mkdir(join(kb(), '
|
|
91
|
-
await mkdir(join(kb(), '
|
|
92
|
-
await writeFile(join(kb(), '
|
|
93
|
-
await
|
|
89
|
+
// folder with content but no access.md is just as much a non-plugin.
|
|
90
|
+
await mkdir(join(kb(), 'Plugins', 'Ghost'), { recursive: true });
|
|
91
|
+
await mkdir(join(kb(), 'Plugins', 'Residue'), { recursive: true });
|
|
92
|
+
await writeFile(join(kb(), 'Plugins', 'Residue', 'notes.md'), 'leftover');
|
|
93
|
+
await pluginDir('Real');
|
|
94
94
|
|
|
95
95
|
const catalog = await svc().catalog();
|
|
96
96
|
expect(catalog.map((g) => g.name)).toEqual(['Real']);
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
test('the retired Skills/ and Tools/ roots are NOT
|
|
99
|
+
test('the retired Skills/ and Tools/ roots are NOT plugin roots', async () => {
|
|
100
100
|
await mkdir(join(kb(), 'Skills', 'GTM'), { recursive: true });
|
|
101
101
|
await mkdir(join(kb(), 'Tools', 'GTM'), { recursive: true });
|
|
102
|
-
await
|
|
102
|
+
await pluginDir('Engineering');
|
|
103
103
|
|
|
104
104
|
const catalog = await svc().catalog();
|
|
105
105
|
expect(catalog.map((g) => g.name)).toEqual(['Engineering']);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
test('ignores loose files and dot-dirs under the
|
|
108
|
+
test('ignores loose files and dot-dirs under the plugin root', async () => {
|
|
109
109
|
// The dot filter wins even over a folder that carries an access.md.
|
|
110
|
-
await mkdir(join(kb(), '
|
|
111
|
-
await writeFile(join(kb(), '
|
|
112
|
-
await
|
|
113
|
-
await writeFile(join(kb(), '
|
|
110
|
+
await mkdir(join(kb(), 'Plugins', '.hidden'), { recursive: true });
|
|
111
|
+
await writeFile(join(kb(), 'Plugins', '.hidden', 'access.md'), '---\nread:\n - everyone\n---\n');
|
|
112
|
+
await pluginDir('GTM');
|
|
113
|
+
await writeFile(join(kb(), 'Plugins', 'slack.tool'), '{}');
|
|
114
114
|
|
|
115
115
|
const catalog = await svc().catalog();
|
|
116
116
|
expect(catalog.map((g) => g.name)).toEqual(['GTM']);
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
test('counts skills and tools from the global catalogs by
|
|
120
|
-
await
|
|
121
|
-
await
|
|
119
|
+
test('counts skills and tools from the global catalogs by pluginOfPath', async () => {
|
|
120
|
+
await pluginDir('GTM');
|
|
121
|
+
await pluginDir('Product');
|
|
122
122
|
|
|
123
123
|
const catalog = await svc({
|
|
124
|
-
skills: skills('
|
|
125
|
-
// `
|
|
126
|
-
tools: tools('
|
|
124
|
+
skills: skills('Plugins/GTM/outreach', 'Plugins/GTM/newsletter', 'Plugins/Product/roadmap'),
|
|
125
|
+
// `Plugins/slack.tool` is ungrouped (two segments) — it counts nowhere.
|
|
126
|
+
tools: tools('Plugins/GTM/heyreach.tool', 'Plugins/slack.tool'),
|
|
127
127
|
}).catalog();
|
|
128
128
|
|
|
129
129
|
const gtm = catalog.find((g) => g.name === 'GTM')!;
|
|
@@ -134,8 +134,8 @@ describe('GroupIndexService', () => {
|
|
|
134
134
|
expect(product.toolCount).toBe(0);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
test('resolves principals on the
|
|
138
|
-
await
|
|
137
|
+
test('resolves principals on the plugin folder', async () => {
|
|
138
|
+
await pluginDir('GTM');
|
|
139
139
|
|
|
140
140
|
const seen: string[] = [];
|
|
141
141
|
const access = {
|
|
@@ -148,13 +148,13 @@ describe('GroupIndexService', () => {
|
|
|
148
148
|
} as unknown as IAccessControl;
|
|
149
149
|
|
|
150
150
|
const catalog = await svc({ access }).catalog();
|
|
151
|
-
expect(catalog[0].folders).toEqual(['
|
|
152
|
-
expect(seen).toEqual(['
|
|
151
|
+
expect(catalog[0].folders).toEqual(['Plugins/GTM']);
|
|
152
|
+
expect(seen).toEqual(['Plugins/GTM']);
|
|
153
153
|
expect(catalog[0].owners.users).toEqual([OLGA]);
|
|
154
154
|
expect(catalog[0].readers).toEqual({ restricted: false, roles: [], users: [] });
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
test('a missing
|
|
157
|
+
test('a missing Plugins/ root yields an empty list, not an error', async () => {
|
|
158
158
|
await expect(svc().catalog()).resolves.toEqual([]);
|
|
159
159
|
});
|
|
160
160
|
|
|
@@ -172,7 +172,7 @@ describe('GroupIndexService', () => {
|
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
test('a failed scan is served but NOT cached — the next call retries', async () => {
|
|
175
|
-
await
|
|
175
|
+
await pluginDir('GTM');
|
|
176
176
|
let fail = true;
|
|
177
177
|
const flaky = {
|
|
178
178
|
// Fails once, the way a default-branch clone mid-creation does, then
|
|
@@ -211,12 +211,12 @@ describe('GroupIndexService', () => {
|
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
test('caches for the TTL and rescans after invalidate()', async () => {
|
|
214
|
-
await
|
|
214
|
+
await pluginDir('GTM');
|
|
215
215
|
const service = svc();
|
|
216
216
|
|
|
217
217
|
expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
|
|
218
|
-
// A
|
|
219
|
-
await
|
|
218
|
+
// A plugin added out of band is NOT seen while the cache holds…
|
|
219
|
+
await pluginDir('Finance');
|
|
220
220
|
expect((await service.catalog()).map((g) => g.name)).toEqual(['GTM']);
|
|
221
221
|
// …and IS seen once the file-change subscriber drops it.
|
|
222
222
|
service.invalidate();
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { DEFAULT_BRANCH, personalPluginFolderName } from '@bevel-software/platform-shared';
|
|
6
|
+
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
7
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
8
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
9
|
+
import {
|
|
10
|
+
PluginProvisionError,
|
|
11
|
+
PluginProvisionService,
|
|
12
|
+
pluginAccessMd,
|
|
13
|
+
personalAccessMd,
|
|
14
|
+
} from '../plugin-provision.service.js';
|
|
15
|
+
|
|
16
|
+
const KB = 'knowledge-base';
|
|
17
|
+
const USER: AuthUser = { id: 'u1-abcd', email: 'ali@example.com', name: 'Ali Vega' } as AuthUser;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The service is exercised against a REAL temp directory (the existence
|
|
21
|
+
* check and the rollback are filesystem semantics, not mockable branches),
|
|
22
|
+
* with the workspace, commit and access dependencies stubbed at their seams.
|
|
23
|
+
*/
|
|
24
|
+
async function makeHarness() {
|
|
25
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-provision-'));
|
|
26
|
+
const writeFile = vi.fn(
|
|
27
|
+
async (_id: string, rel: string, content: string, opts?: { failIfExists?: boolean }) => {
|
|
28
|
+
const abs = path.join(dir, rel);
|
|
29
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
30
|
+
try {
|
|
31
|
+
await fs.writeFile(abs, content, { encoding: 'utf-8', flag: opts?.failIfExists ? 'wx' : 'w' });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
34
|
+
const conflict: Error & { status?: number } = new Error(`"${rel}" already exists.`);
|
|
35
|
+
conflict.status = 409;
|
|
36
|
+
throw conflict;
|
|
37
|
+
}
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
const workspaceService = {
|
|
43
|
+
getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-main' })),
|
|
44
|
+
getWorkspacePath: vi.fn(async () => dir),
|
|
45
|
+
writeFile,
|
|
46
|
+
} as unknown as WorkspaceService;
|
|
47
|
+
const commits = { runPendingCommit: vi.fn(async () => undefined) };
|
|
48
|
+
const accessControl = { invalidate: vi.fn() } as unknown as IAccessControl;
|
|
49
|
+
const events = { emit: vi.fn() };
|
|
50
|
+
const svc = new PluginProvisionService(workspaceService, commits, accessControl, KB, events);
|
|
51
|
+
return { svc, dir, commits, accessControl, events, writeFile };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('PluginProvisionService.createPlugin', () => {
|
|
55
|
+
let h: Awaited<ReturnType<typeof makeHarness>>;
|
|
56
|
+
beforeEach(async () => {
|
|
57
|
+
h = await makeHarness();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('writes the discoverable template, commits it inline, and drops the access cache', async () => {
|
|
61
|
+
const result = await h.svc.createPlugin(USER, 'GTM');
|
|
62
|
+
expect(result).toEqual({ folder: 'GTM', created: true });
|
|
63
|
+
|
|
64
|
+
const accessMd = await fs.readFile(path.join(h.dir, KB, 'Plugins/GTM/access.md'), 'utf-8');
|
|
65
|
+
// Discoverable FILE (frontmatter read: everyone), creator-run FOLDER
|
|
66
|
+
// (body names the creator under all three verbs).
|
|
67
|
+
expect(accessMd.startsWith('---\nread:\n - everyone\n---\n')).toBe(true);
|
|
68
|
+
const body = accessMd.slice(accessMd.indexOf('---\n', 4) + 4);
|
|
69
|
+
for (const verb of ['read', 'write', 'owner']) {
|
|
70
|
+
expect(body).toMatch(new RegExp(`${verb}:[\\s\\S]*Ali Vega <ali@example.com>`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// The commit ran INLINE — the gate reads at HEAD, so an async commit
|
|
74
|
+
// would 403 the creator's very next write into the folder.
|
|
75
|
+
// `systemAuthorized`: the endpoint is the authorization — without it the
|
|
76
|
+
// push gate reads origin (where the folder does not exist) and refuses
|
|
77
|
+
// every non-admin the product promised a plugin to.
|
|
78
|
+
expect(h.commits.runPendingCommit).toHaveBeenCalledWith(
|
|
79
|
+
'ws-main',
|
|
80
|
+
DEFAULT_BRANCH,
|
|
81
|
+
// FOLDER-scoped: plugin.json must land in the same commit as the rules,
|
|
82
|
+
// or the folder is briefly a plugin to us and not to any other client.
|
|
83
|
+
`${KB}/Plugins/GTM`,
|
|
84
|
+
USER,
|
|
85
|
+
{ systemAuthorized: true },
|
|
86
|
+
);
|
|
87
|
+
expect(h.accessControl.invalidate).toHaveBeenCalledWith('ws-main');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('writes a conformant plugin.json naming the folder in slug form', async () => {
|
|
91
|
+
await h.svc.createPlugin(USER, 'GTM');
|
|
92
|
+
const manifest = JSON.parse(
|
|
93
|
+
await fs.readFile(path.join(h.dir, KB, 'Plugins/GTM/plugin.json'), 'utf-8'),
|
|
94
|
+
);
|
|
95
|
+
expect(manifest).toEqual({
|
|
96
|
+
$schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
|
|
97
|
+
name: 'gtm',
|
|
98
|
+
});
|
|
99
|
+
// The schema's `name` pattern is the thing a conformant client refuses on.
|
|
100
|
+
expect(manifest.name).toMatch(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('refuses a taken name case-insensitively with 409', async () => {
|
|
104
|
+
await h.svc.createPlugin(USER, 'GTM');
|
|
105
|
+
await expect(h.svc.createPlugin(USER, 'gtm')).rejects.toMatchObject({ status: 409 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('refuses a name whose manifest slug is already claimed by another folder with 409', async () => {
|
|
109
|
+
// The manifest `name` is a LOSSY slug of the folder — `Sales Team` and
|
|
110
|
+
// `Sales-Team` both become `sales-team` — and it is the identity a
|
|
111
|
+
// conformant client keys plugins on. Folder uniqueness alone would let
|
|
112
|
+
// two plugins publish one name.
|
|
113
|
+
await h.svc.createPlugin(USER, 'Sales Team');
|
|
114
|
+
await expect(h.svc.createPlugin(USER, 'Sales-Team')).rejects.toMatchObject({ status: 409 });
|
|
115
|
+
await expect(h.svc.createPlugin(USER, 'Sales_Team')).rejects.toMatchObject({ status: 409 });
|
|
116
|
+
// Only the first folder landed.
|
|
117
|
+
expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toEqual(['Sales Team']);
|
|
118
|
+
// A genuinely distinct slug still goes through.
|
|
119
|
+
await expect(h.svc.createPlugin(USER, 'Sales Ops')).resolves.toEqual({
|
|
120
|
+
folder: 'Sales Ops',
|
|
121
|
+
created: true,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('serialises concurrent creations of two SPELLINGS of one slug — exactly one lands', async () => {
|
|
126
|
+
// Different lowercased names take different name-keyed locks; only a
|
|
127
|
+
// slug-keyed lock makes the twin check above hold under concurrency.
|
|
128
|
+
const results = await Promise.allSettled([
|
|
129
|
+
h.svc.createPlugin(USER, 'Growth Team'),
|
|
130
|
+
h.svc.createPlugin(USER, 'Growth-Team'),
|
|
131
|
+
]);
|
|
132
|
+
const ok = results.filter((r) => r.status === 'fulfilled');
|
|
133
|
+
const failed = results.filter(
|
|
134
|
+
(r) => r.status === 'rejected' && (r.reason as { status?: number }).status === 409,
|
|
135
|
+
);
|
|
136
|
+
expect(ok).toHaveLength(1);
|
|
137
|
+
expect(failed).toHaveLength(1);
|
|
138
|
+
expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toHaveLength(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('a loose file at the Plugins root claims no slug — "Slack Tool" is not its twin', async () => {
|
|
142
|
+
// Loose files are not plugins (the catalog skips them); only a real
|
|
143
|
+
// plugin FOLDER publishes a manifest identity.
|
|
144
|
+
await fs.mkdir(path.join(h.dir, KB, 'Plugins'), { recursive: true });
|
|
145
|
+
await fs.writeFile(path.join(h.dir, KB, 'Plugins', 'slack.tool'), 'id: slack\n', 'utf-8');
|
|
146
|
+
await expect(h.svc.createPlugin(USER, 'Slack Tool')).resolves.toEqual({
|
|
147
|
+
folder: 'Slack Tool',
|
|
148
|
+
created: true,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('refuses names the filesystem or the model cannot carry with 422', async () => {
|
|
153
|
+
for (const bad of ['', ' ', 'a/b', 'a\\b', '.', '..', '.hidden', 'personal-anything', 'Personal Abc', 'a\u0000b', 'a\tb']) {
|
|
154
|
+
await expect(h.svc.createPlugin(USER, bad)).rejects.toBeInstanceOf(PluginProvisionError);
|
|
155
|
+
await expect(h.svc.createPlugin(USER, bad)).rejects.toMatchObject({ status: 422 });
|
|
156
|
+
}
|
|
157
|
+
// Nothing landed on disk for any of them.
|
|
158
|
+
await expect(fs.readdir(path.join(h.dir, KB, 'Plugins'))).rejects.toThrow();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('serialises concurrent creations of one name in different casings — exactly one lands', async () => {
|
|
162
|
+
// On a case-sensitive filesystem `GTM` and `gtm` are different paths, so
|
|
163
|
+
// the wx writes alone would BOTH succeed; the per-name lock is what makes
|
|
164
|
+
// the case-insensitive uniqueness hold under concurrency.
|
|
165
|
+
const results = await Promise.allSettled([
|
|
166
|
+
h.svc.createPlugin(USER, 'GTM'),
|
|
167
|
+
h.svc.createPlugin(USER, 'gtm'),
|
|
168
|
+
]);
|
|
169
|
+
const ok = results.filter((r) => r.status === 'fulfilled');
|
|
170
|
+
const refused = results.filter(
|
|
171
|
+
(r) => r.status === 'rejected' && (r.reason as PluginProvisionError).status === 409,
|
|
172
|
+
);
|
|
173
|
+
expect(ok).toHaveLength(1);
|
|
174
|
+
expect(refused).toHaveLength(1);
|
|
175
|
+
expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toHaveLength(1);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rolls the seeded file back when the commit fails, so a retry is not told "already exists"', async () => {
|
|
179
|
+
h.commits.runPendingCommit.mockRejectedValueOnce(new Error('push refused'));
|
|
180
|
+
await expect(h.svc.createPlugin(USER, 'GTM')).rejects.toThrow('push refused');
|
|
181
|
+
// The folder is gone again — the next attempt starts clean.
|
|
182
|
+
await expect(fs.stat(path.join(h.dir, KB, 'Plugins/GTM'))).rejects.toThrow();
|
|
183
|
+
await expect(h.svc.createPlugin(USER, 'GTM')).resolves.toEqual({ folder: 'GTM', created: true });
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('PluginProvisionService.deletePlugin', () => {
|
|
188
|
+
let h: Awaited<ReturnType<typeof makeHarness>>;
|
|
189
|
+
beforeEach(async () => {
|
|
190
|
+
h = await makeHarness();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('removes the whole folder in ONE folder-scoped commit, and drops the access cache', async () => {
|
|
194
|
+
await h.svc.createPlugin(USER, 'GTM');
|
|
195
|
+
// A plugin with content — the delete takes the skills with the folder.
|
|
196
|
+
await fs.mkdir(path.join(h.dir, KB, 'Plugins/GTM/outreach'), { recursive: true });
|
|
197
|
+
await fs.writeFile(path.join(h.dir, KB, 'Plugins/GTM/outreach/SKILL.md'), '# outreach\n');
|
|
198
|
+
h.commits.runPendingCommit.mockClear();
|
|
199
|
+
(h.accessControl.invalidate as ReturnType<typeof vi.fn>).mockClear();
|
|
200
|
+
|
|
201
|
+
await h.svc.deletePlugin(USER, 'GTM');
|
|
202
|
+
|
|
203
|
+
await expect(fs.stat(path.join(h.dir, KB, 'Plugins/GTM'))).rejects.toThrow();
|
|
204
|
+
// No parked remnant either — the commit landed, so the bytes may go.
|
|
205
|
+
expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toEqual([]);
|
|
206
|
+
// FOLDER-scoped (`git add -- <folder>` stages every deletion under it),
|
|
207
|
+
// inline, and `systemAuthorized` — the endpoint already authorized the
|
|
208
|
+
// delete (owner verdict), and the per-user push gate would re-read the
|
|
209
|
+
// very access.md this commit removes.
|
|
210
|
+
expect(h.commits.runPendingCommit).toHaveBeenCalledWith(
|
|
211
|
+
'ws-main',
|
|
212
|
+
DEFAULT_BRANCH,
|
|
213
|
+
`${KB}/Plugins/GTM`,
|
|
214
|
+
USER,
|
|
215
|
+
{ systemAuthorized: true },
|
|
216
|
+
);
|
|
217
|
+
expect(h.accessControl.invalidate).toHaveBeenCalledWith('ws-main');
|
|
218
|
+
expect(h.events.emit).toHaveBeenCalledWith(
|
|
219
|
+
expect.objectContaining({ kind: 'fs-tree-changed' }),
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('refuses an unknown name — and a casing mismatch, which is the same thing — with 404', async () => {
|
|
224
|
+
await h.svc.createPlugin(USER, 'GTM');
|
|
225
|
+
for (const name of ['Nope', 'gtm']) {
|
|
226
|
+
await expect(h.svc.deletePlugin(USER, name)).rejects.toMatchObject({ status: 404 });
|
|
227
|
+
}
|
|
228
|
+
expect(await fs.readdir(path.join(h.dir, KB, 'Plugins'))).toEqual(['GTM']);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('never deletes a personal folder through the plugin door', async () => {
|
|
232
|
+
await h.svc.ensurePersonalPlugin(USER);
|
|
233
|
+
const folder = personalPluginFolderName(USER.id);
|
|
234
|
+
await expect(h.svc.deletePlugin(USER, folder)).rejects.toMatchObject({ status: 404 });
|
|
235
|
+
await expect(fs.stat(path.join(h.dir, KB, 'Plugins', folder))).resolves.toBeDefined();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('puts the folder back, content intact, when the commit is refused', async () => {
|
|
239
|
+
await h.svc.createPlugin(USER, 'GTM');
|
|
240
|
+
await fs.mkdir(path.join(h.dir, KB, 'Plugins/GTM/outreach'), { recursive: true });
|
|
241
|
+
await fs.writeFile(path.join(h.dir, KB, 'Plugins/GTM/outreach/SKILL.md'), '# outreach\n');
|
|
242
|
+
h.commits.runPendingCommit.mockRejectedValueOnce(new Error('push refused'));
|
|
243
|
+
|
|
244
|
+
await expect(h.svc.deletePlugin(USER, 'GTM')).rejects.toThrow('push refused');
|
|
245
|
+
|
|
246
|
+
// A failed delete is a NO-OP: origin still carries the plugin, so the
|
|
247
|
+
// working tree must too — bytes included, not just the folder shell.
|
|
248
|
+
expect(
|
|
249
|
+
await fs.readFile(path.join(h.dir, KB, 'Plugins/GTM/outreach/SKILL.md'), 'utf-8'),
|
|
250
|
+
).toBe('# outreach\n');
|
|
251
|
+
// And the retry goes through.
|
|
252
|
+
await expect(h.svc.deletePlugin(USER, 'GTM')).resolves.toBeUndefined();
|
|
253
|
+
await expect(fs.stat(path.join(h.dir, KB, 'Plugins/GTM'))).rejects.toThrow();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('PluginProvisionService.ensurePersonalPlugin', () => {
|
|
258
|
+
it('creates the private personal folder once, then reports it as existing', async () => {
|
|
259
|
+
const h = await makeHarness();
|
|
260
|
+
const folder = personalPluginFolderName(USER.id);
|
|
261
|
+
|
|
262
|
+
const first = await h.svc.ensurePersonalPlugin(USER);
|
|
263
|
+
expect(first).toEqual({ folder, created: true });
|
|
264
|
+
const accessMd = await fs.readFile(
|
|
265
|
+
path.join(h.dir, KB, 'Plugins', folder, 'access.md'),
|
|
266
|
+
'utf-8',
|
|
267
|
+
);
|
|
268
|
+
// PRIVATE: no `everyone` self-grant anywhere — the file follows the
|
|
269
|
+
// folder chain, and the rules close that chain to the owner alone.
|
|
270
|
+
expect(accessMd).not.toContain('everyone');
|
|
271
|
+
for (const verb of ['read', 'write', 'owner']) {
|
|
272
|
+
expect(accessMd).toMatch(new RegExp(`${verb}:[\\s\\S]*Ali Vega <ali@example.com>`));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const second = await h.svc.ensurePersonalPlugin(USER);
|
|
276
|
+
expect(second).toEqual({ folder, created: false });
|
|
277
|
+
// Idempotent for real: one provision (access.md + plugin.json), one commit.
|
|
278
|
+
expect(h.writeFile).toHaveBeenCalledTimes(2);
|
|
279
|
+
expect(h.commits.runPendingCommit).toHaveBeenCalledTimes(1);
|
|
280
|
+
// A personal folder is a plugin too — its id is slugged from the folder,
|
|
281
|
+
// which is where a doubled separator would have produced an invalid name.
|
|
282
|
+
const manifest = JSON.parse(
|
|
283
|
+
await fs.readFile(path.join(h.dir, KB, 'Plugins', folder, 'plugin.json'), 'utf-8'),
|
|
284
|
+
);
|
|
285
|
+
expect(manifest.name).toMatch(/^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('two concurrent ensures both succeed — one creates, the other reports existing', async () => {
|
|
289
|
+
const h = await makeHarness();
|
|
290
|
+
const folder = personalPluginFolderName(USER.id);
|
|
291
|
+
const [a, b] = await Promise.all([
|
|
292
|
+
h.svc.ensurePersonalPlugin(USER),
|
|
293
|
+
h.svc.ensurePersonalPlugin(USER),
|
|
294
|
+
]);
|
|
295
|
+
expect([a.created, b.created].sort()).toEqual([false, true]);
|
|
296
|
+
expect(a.folder).toBe(folder);
|
|
297
|
+
expect(b.folder).toBe(folder);
|
|
298
|
+
expect(h.commits.runPendingCommit).toHaveBeenCalledTimes(1);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('access.md templates', () => {
|
|
303
|
+
it('plugin template is discoverable, personal template is not — same creator grants in both', () => {
|
|
304
|
+
const plugin = pluginAccessMd(USER);
|
|
305
|
+
const personal = personalAccessMd(USER);
|
|
306
|
+
expect(plugin).toContain('everyone');
|
|
307
|
+
expect(personal).not.toContain('everyone');
|
|
308
|
+
for (const text of [plugin, personal]) {
|
|
309
|
+
expect(text).toContain('Ali Vega <ali@example.com>');
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|