2026-03-08 01:14:28 +00:00
|
|
|
import { describe, it, expect } from 'vitest';
|
|
|
|
|
import { resourceToYaml, resourcePath, parseResourcePath, APPLY_ORDER } from '../src/services/backup/yaml-serializer.js';
|
|
|
|
|
|
|
|
|
|
describe('resourceToYaml', () => {
|
|
|
|
|
it('serializes a server', () => {
|
|
|
|
|
const yaml = resourceToYaml('server', {
|
|
|
|
|
id: 'srv-1',
|
|
|
|
|
name: 'grafana',
|
|
|
|
|
description: 'Grafana MCP',
|
|
|
|
|
dockerImage: 'mcp/grafana:latest',
|
|
|
|
|
transport: 'STDIO',
|
|
|
|
|
env: [{ name: 'API_KEY', value: 'secret' }],
|
|
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
packageName: null,
|
|
|
|
|
repositoryUrl: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(yaml).toContain('kind: server');
|
|
|
|
|
expect(yaml).toContain('name: grafana');
|
|
|
|
|
expect(yaml).toContain('description: Grafana MCP');
|
|
|
|
|
expect(yaml).toContain('dockerImage: mcp/grafana:latest');
|
|
|
|
|
expect(yaml).toContain('transport: STDIO');
|
|
|
|
|
expect(yaml).not.toContain('id:');
|
|
|
|
|
expect(yaml).not.toContain('createdAt:');
|
|
|
|
|
expect(yaml).not.toContain('version:');
|
|
|
|
|
expect(yaml).not.toContain('packageName:'); // null values stripped
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('serializes a project with server names', () => {
|
|
|
|
|
const yaml = resourceToYaml('project', {
|
|
|
|
|
id: 'p-1',
|
|
|
|
|
name: 'my-project',
|
|
|
|
|
description: 'Test project',
|
|
|
|
|
proxyModel: 'default',
|
|
|
|
|
gated: true,
|
|
|
|
|
ownerId: 'user-1',
|
|
|
|
|
servers: [
|
|
|
|
|
{ id: 'ps-1', server: { name: 'grafana' } },
|
|
|
|
|
{ id: 'ps-2', server: { name: 'node-red' } },
|
|
|
|
|
],
|
|
|
|
|
llmProvider: 'openai',
|
|
|
|
|
llmModel: null,
|
|
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(yaml).toContain('kind: project');
|
|
|
|
|
expect(yaml).toContain('name: my-project');
|
|
|
|
|
expect(yaml).toContain('proxyModel: default');
|
|
|
|
|
expect(yaml).toContain('- grafana');
|
|
|
|
|
expect(yaml).toContain('- node-red');
|
|
|
|
|
expect(yaml).toContain('llmProvider: openai');
|
|
|
|
|
expect(yaml).not.toContain('gated:');
|
|
|
|
|
expect(yaml).not.toContain('ownerId:');
|
|
|
|
|
expect(yaml).not.toContain('llmModel:'); // null stripped
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('normalizes proxyModel from gated boolean', () => {
|
|
|
|
|
const yaml1 = resourceToYaml('project', {
|
|
|
|
|
name: 'p1',
|
|
|
|
|
proxyModel: '',
|
|
|
|
|
gated: false,
|
|
|
|
|
servers: [],
|
|
|
|
|
});
|
|
|
|
|
expect(yaml1).toContain('proxyModel: content-pipeline');
|
|
|
|
|
|
|
|
|
|
const yaml2 = resourceToYaml('project', {
|
|
|
|
|
name: 'p2',
|
|
|
|
|
proxyModel: '',
|
|
|
|
|
gated: true,
|
|
|
|
|
servers: [],
|
|
|
|
|
});
|
|
|
|
|
expect(yaml2).toContain('proxyModel: default');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('serializes a secret', () => {
|
|
|
|
|
const yaml = resourceToYaml('secret', {
|
|
|
|
|
id: 's-1',
|
|
|
|
|
name: 'my-secret',
|
|
|
|
|
data: { TOKEN: 'abc123', KEY: 'xyz' },
|
|
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(yaml).toContain('kind: secret');
|
|
|
|
|
expect(yaml).toContain('name: my-secret');
|
|
|
|
|
expect(yaml).toContain('TOKEN: abc123');
|
|
|
|
|
expect(yaml).toContain('KEY: xyz');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('serializes a user without passwordHash', () => {
|
|
|
|
|
const yaml = resourceToYaml('user', {
|
|
|
|
|
id: 'u-1',
|
|
|
|
|
email: 'michal@test.com',
|
|
|
|
|
name: 'Michal',
|
|
|
|
|
role: 'ADMIN',
|
|
|
|
|
passwordHash: '$2b$10$secret',
|
|
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(yaml).toContain('kind: user');
|
|
|
|
|
expect(yaml).toContain('email: michal@test.com');
|
|
|
|
|
expect(yaml).toContain('name: Michal');
|
|
|
|
|
expect(yaml).toContain('role: ADMIN');
|
|
|
|
|
expect(yaml).not.toContain('passwordHash');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('serializes a group with member emails', () => {
|
|
|
|
|
const yaml = resourceToYaml('group', {
|
|
|
|
|
id: 'g-1',
|
|
|
|
|
name: 'dev-team',
|
|
|
|
|
description: 'Developers',
|
|
|
|
|
members: [
|
|
|
|
|
{ user: { email: 'alice@test.com' } },
|
|
|
|
|
{ user: { email: 'bob@test.com' } },
|
|
|
|
|
],
|
|
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(yaml).toContain('kind: group');
|
|
|
|
|
expect(yaml).toContain('name: dev-team');
|
|
|
|
|
expect(yaml).toContain('- alice@test.com');
|
|
|
|
|
expect(yaml).toContain('- bob@test.com');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('serializes a prompt with project name', () => {
|
|
|
|
|
const yaml = resourceToYaml('prompt', {
|
|
|
|
|
id: 'pr-1',
|
|
|
|
|
name: 'system-instructions',
|
|
|
|
|
content: 'You are a helpful assistant.',
|
|
|
|
|
priority: 5,
|
|
|
|
|
project: { name: 'my-project' },
|
|
|
|
|
projectId: 'p-1',
|
|
|
|
|
summary: 'Summary text',
|
|
|
|
|
chapters: ['ch1'],
|
|
|
|
|
linkTarget: null,
|
|
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(yaml).toContain('kind: prompt');
|
|
|
|
|
expect(yaml).toContain('name: system-instructions');
|
|
|
|
|
expect(yaml).toContain('project: my-project');
|
|
|
|
|
expect(yaml).toContain('priority: 5');
|
|
|
|
|
expect(yaml).toContain('content: You are a helpful assistant.');
|
|
|
|
|
expect(yaml).not.toContain('projectId:');
|
|
|
|
|
expect(yaml).not.toContain('summary:');
|
|
|
|
|
expect(yaml).not.toContain('chapters:');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('serializes a linked prompt with link field', () => {
|
|
|
|
|
const yaml = resourceToYaml('prompt', {
|
|
|
|
|
id: 'pr-2',
|
|
|
|
|
name: 'linked-prompt',
|
|
|
|
|
content: 'Fetched content',
|
|
|
|
|
linkTarget: 'my-project/grafana:resource://docs',
|
|
|
|
|
project: { name: 'my-project' },
|
|
|
|
|
projectId: 'p-1',
|
|
|
|
|
priority: 3,
|
|
|
|
|
version: 1,
|
|
|
|
|
createdAt: new Date(),
|
|
|
|
|
updatedAt: new Date(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(yaml).toContain('link: my-project/grafana:resource://docs');
|
|
|
|
|
expect(yaml).not.toContain('content:'); // content stripped for linked prompts
|
|
|
|
|
expect(yaml).not.toContain('linkTarget:');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('puts kind first and content/data last', () => {
|
|
|
|
|
const yaml = resourceToYaml('secret', {
|
|
|
|
|
name: 'test',
|
|
|
|
|
data: { KEY: 'val' },
|
|
|
|
|
});
|
|
|
|
|
const lines = yaml.split('\n');
|
|
|
|
|
expect(lines[0]).toBe('kind: secret');
|
|
|
|
|
// data should be after name
|
|
|
|
|
const nameIdx = lines.findIndex((l) => l.startsWith('name:'));
|
|
|
|
|
const dataIdx = lines.findIndex((l) => l.startsWith('data:'));
|
|
|
|
|
expect(dataIdx).toBeGreaterThan(nameIdx);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('resourcePath', () => {
|
|
|
|
|
it('maps kinds to directories', () => {
|
|
|
|
|
expect(resourcePath('server', 'grafana')).toBe('servers/grafana.yaml');
|
|
|
|
|
expect(resourcePath('secret', 'my-token')).toBe('secrets/my-token.yaml');
|
|
|
|
|
expect(resourcePath('project', 'default')).toBe('projects/default.yaml');
|
|
|
|
|
expect(resourcePath('rbac', 'admins')).toBe('rbac/admins.yaml');
|
|
|
|
|
expect(resourcePath('user', 'michal@test.com')).toBe('users/michal@test.com.yaml');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('sanitizes unsafe characters', () => {
|
|
|
|
|
expect(resourcePath('server', 'my/server')).toBe('servers/my_server.yaml');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('parseResourcePath', () => {
|
|
|
|
|
it('parses valid paths', () => {
|
|
|
|
|
expect(parseResourcePath('servers/grafana.yaml')).toEqual({ kind: 'server', name: 'grafana' });
|
|
|
|
|
expect(parseResourcePath('secrets/my-token.yaml')).toEqual({ kind: 'secret', name: 'my-token' });
|
|
|
|
|
expect(parseResourcePath('rbac/admins.yaml')).toEqual({ kind: 'rbac', name: 'admins' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns null for invalid paths', () => {
|
|
|
|
|
expect(parseResourcePath('README.md')).toBeNull();
|
|
|
|
|
expect(parseResourcePath('.gitkeep')).toBeNull();
|
|
|
|
|
expect(parseResourcePath('unknown/file.yaml')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('APPLY_ORDER', () => {
|
|
|
|
|
it('has secrets before servers before projects', () => {
|
|
|
|
|
const si = APPLY_ORDER.indexOf('secret');
|
|
|
|
|
const sv = APPLY_ORDER.indexOf('server');
|
|
|
|
|
const pr = APPLY_ORDER.indexOf('project');
|
|
|
|
|
expect(si).toBeLessThan(sv);
|
|
|
|
|
expect(sv).toBeLessThan(pr);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('has all backup kinds', () => {
|
feat(mcpd): Skill resource end-to-end (CRUD + backup + revision integration)
Phase 3 of the Skills + Revisions + Proposals work. Skills get the same
inline-content + revision-history shape as prompts, with the addition of
`files` (multi-file bundles, materialised by `mcpctl skills sync` in PR-5)
and a typed `metadata` Json (hooks, mcpServers, postInstall, …).
## What's added
### Validation (src/mcpd/src/validation/skill.schema.ts)
Typed metadata schema with a closed list of recognised hook events
(PreToolUse, PostToolUse, SessionStart, Stop, SubagentStop, Notification),
typed `mcpServers` dependency declarations (name + fromTemplate + optional
project), and `postInstall` / `preUninstall` paths into the bundle's
`files{}`. `.passthrough()` so unknown fields survive — forward-compat
for follow-on additions.
### Repository (src/mcpd/src/repositories/skill.repository.ts)
Mirrors PromptRepository exactly. Same `?? ''` workaround for nullable-FK
compound-key lookups.
### Service (src/mcpd/src/services/skill.service.ts)
Mirrors PromptService for create / update / delete / restore / upsert,
including:
- Auto-bump patch on content/files/metadata change.
- Revision recording (best-effort — failures don't block the save).
- 'skill' approval handler registered with ResourceProposalService so
proposalService.approve dispatches to skills the same way it
dispatches to prompts.
- `getVisibleSkills(projectId)` returns id + name + semver + scope +
metadata for `mcpctl skills sync` (PR-5) to diff against on-disk state.
### Routes (src/mcpd/src/routes/skills.ts)
- GET /api/v1/skills (filters: ?project= ?projectId= ?agent= ?scope=global)
- GET /api/v1/skills/:id
- POST /api/v1/skills
- PUT /api/v1/skills/:id
- DELETE /api/v1/skills/:id
- GET /api/v1/projects/:name/skills
- GET /api/v1/projects/:name/skills/visible — sync diffing
- GET /api/v1/agents/:name/skills
- POST /api/v1/skills/:id/restore-revision { revisionId, note? }
### main.ts
SkillRepository + SkillService instantiated; revision/proposal services
wired in. `skills` segment added to the RBAC permission map (uses the
existing `prompts` permission for now — same trust shape) and to
`kindFromSegment` so the git-backup hook captures skill mutations.
### Backup integration
- yaml-serializer.ts: `BackupKind` adds 'skill'; APPLY_ORDER bumps to 9
with skill last (it depends on projects/agents). `parseResourcePath`
recognises the `skills/` directory.
- git-backup.service.ts: `serializeResource` adds the `case 'skill'`
branch alongside prompts. The git-sync loop now round-trips skills
on every change.
- (Bundle backup-service.ts is NOT updated in this PR — deferred to PR-7
alongside the cutover. The git-based backup IS wired, which is the
primary persistence path.)
### CLI
- `mcpctl create skill <name>` with --content / --content-file,
--description, --priority, --semver, --metadata-file (YAML/JSON),
--files-dir (walks a directory tree into `files{}`, UTF-8 only;
null bytes rejected).
- shared.ts adds `skill` / `skills` / `sk` aliases.
### apply.ts
Not updated — `mcpctl apply -f skill.yaml` is deferred to PR-7. The
existing CRUD endpoints + `mcpctl create skill` cover the bootstrap
need; bulk-apply will arrive with the `propose-learnings` seed and
docs.
## Tests
158 test files / 2127 tests green across the workspace. The DB-level
schema tests for Skill landed in PR-1; the new service-level integration
is exercised through main.ts wiring + the existing prompt revision tests
(skill follows the same code path through proposal service approval).
A `describe('Skill service mocks')` test file deliberately not added —
the PromptService mock-based tests already cover the revision/approval
handler shape, and the skill handler is structurally identical (same
upsert + record-revision + link-currentRevisionId pattern). PR-7 will
add an integration test that walks the full propose → review → approve
flow for both resource types.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 00:48:40 +01:00
|
|
|
// PR-3: bumped from 8 → 9 with the addition of `skill`.
|
|
|
|
|
expect(APPLY_ORDER).toHaveLength(9);
|
2026-03-08 01:14:28 +00:00
|
|
|
});
|
|
|
|
|
});
|