feat(mcpd): ResourceRevision + ResourceProposal services + Prompt revision integration

Phase 2 of the Skills + Revisions + Proposals work. Stands up the generic
revision/proposal layer and wires Prompt into it. Skills will plug into the
same infrastructure in PR-3 with no further service changes required.

This PR is intentionally additive: PromptRequest table and routes are
unchanged. The /api/v1/proposals API runs side-by-side with the legacy
/api/v1/promptrequests API. The PromptRequest cutover (rename + backfill +
mcplocal rewire) is deferred to a later PR so this one stays reviewable.

## What's added

### Repositories (src/mcpd/src/repositories/)
- resource-revision.repository.ts — append-only revision log keyed by
  (resourceType, resourceId). Soft FK; no relations declared. Supports
  history listing, semver lookup, and contentHash cross-resource search.
- resource-proposal.repository.ts — generic propose queue. Status lifecycle
  pending → approved | rejected. Mirrors Prompt's `?? ''` workaround for
  nullable-FK compound lookups.

### Services (src/mcpd/src/services/)
- resource-revision.service.ts — record() inserts a revision with a stable
  sha256 contentHash computed from canonicalised JSON (key-sorted at every
  level so reordered objects produce the same hash). Caller passes a
  pre-computed semver; service does NOT decide bump policy.
- resource-proposal.service.ts — propose / approve / reject / list, with a
  per-resourceType handler registry. PromptService registers the 'prompt'
  handler at construction; the SkillService will register 'skill' in PR-3.
  approve() runs in a Prisma $transaction so the resource update + revision
  insert + proposal status flip are atomic.

### Pure utility (src/mcpd/src/utils/semver.ts)
- bumpSemver(current, kind) for major / minor / patch
- compareSemver(a, b) — numeric, not lex (10 > 9)
- isValidSemver(s)
- Invalid input falls back to '0.1.0' rather than throwing — keeps the
  audit-write path from blowing up the prompt update if a row's semver
  ever drifts out of MAJOR.MINOR.PATCH shape.

### Routes (src/mcpd/src/routes/)
- revisions.ts — GET /api/v1/revisions?resourceType=&resourceId=,
  GET /api/v1/revisions/:id, GET /api/v1/revisions/:id/diff?against=<id|live>
  (unified-format diff via the `diff` package), and POST
  /api/v1/prompts/:id/restore-revision { revisionId, note? }.
- proposals.ts — GET / POST /api/v1/proposals,
  GET /api/v1/proposals/:id, PUT for body updates, POST .../approve and
  POST .../reject, plus DELETE.

## What's changed

- PromptService.create / update now record a ResourceRevision when the
  revision service is wired. Update auto-bumps patch on content change;
  authors can override via `--bump major|minor|patch` or `--semver X.Y.Z`
  on the CLI (forwarded into the PUT body). Best-effort: revision write
  failures are swallowed so the prompt save still succeeds (revision is
  audit, not source of truth).
- PromptService.setProposalService registers a 'prompt' approval handler
  with the proposal service. Approval runs in a Prisma transaction:
  upsert prompt → record revision → update currentRevisionId → flip
  proposal status. semver bumps to 0.1.0 on first approval, patch
  thereafter.
- New CLI flags on `mcpctl edit prompt`: --bump, --semver, --note. They're
  prompt-only (validated client-side); other resources reject them.
- Aliases in shared.ts: `proposal`/`prop` → proposals,
  `revision`/`rev` → revisions.
- diff dependency added to mcpd.

## Tests

- src/mcpd/tests/utils/semver.test.ts — covers bump/compare/validate
  including numeric (not lex) semver compare and invalid-input fallback.
- prompt-service.test.ts updated: makePrompt fixture now sets semver +
  agentId + currentRevisionId; updatePrompt assertion expects the
  auto-bumped patch in the same update call.
- prompt-routes.test.ts updated symmetrically.

## RBAC

`proposals` and `revisions` URL segments map to the existing `prompts`
permission for now. PR-7 may split if a "reviewer" role becomes useful.

## Verification

Full suite: 158 test files / 2127 tests green.
`pnpm build` clean across all 6 workspace packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-05-07 00:38:35 +01:00
parent fbe68fa693
commit 1ec286bb14
20 changed files with 1126 additions and 7 deletions

View File

@@ -37,7 +37,10 @@ export function createEditCommand(deps: EditCommandDeps): Command {
.description('Edit a resource in your default editor (server, project)')
.argument('<resource>', 'Resource type (server, project)')
.argument('<name-or-id>', 'Resource name or ID')
.action(async (resourceArg: string, nameOrId: string) => {
.option('--bump <kind>', 'Bump prompt semver after edit: major | minor | patch')
.option('--semver <version>', 'Set prompt semver explicitly (X.Y.Z)')
.option('--note <message>', 'Note attached to the resulting revision')
.action(async (resourceArg: string, nameOrId: string, opts: { bump?: string; semver?: string; note?: string }) => {
const resource = resolveResource(resourceArg);
// Instances are immutable
@@ -55,6 +58,23 @@ export function createEditCommand(deps: EditCommandDeps): Command {
return;
}
// Validation for prompt-only revision flags
if ((opts.bump !== undefined || opts.semver !== undefined || opts.note !== undefined) && resource !== 'prompts') {
log('Error: --bump, --semver, and --note are only valid for prompts');
process.exitCode = 1;
return;
}
if (opts.bump !== undefined && opts.semver !== undefined) {
log('Error: pass --bump or --semver, not both');
process.exitCode = 1;
return;
}
if (opts.bump !== undefined && !['major', 'minor', 'patch'].includes(opts.bump)) {
log("Error: --bump must be 'major', 'minor', or 'patch'");
process.exitCode = 1;
return;
}
// Resolve name → ID
const id = await resolveNameOrId(client, resource, nameOrId);
@@ -102,6 +122,12 @@ export function createEditCommand(deps: EditCommandDeps): Command {
// Parse and apply
const updates = yaml.load(modifiedClean) as Record<string, unknown>;
// Append semver-related flags for prompts (server-side bumps + records revision).
if (resource === 'prompts') {
if (opts.bump !== undefined) updates.bump = opts.bump;
if (opts.semver !== undefined) updates.semver = opts.semver;
if (opts.note !== undefined) updates.note = opts.note;
}
await client.put(`/api/v1/${resource}/${id}`, updates);
log(`${singular} '${nameOrId}' updated.`);
} finally {

View File

@@ -21,6 +21,14 @@ export const RESOURCE_ALIASES: Record<string, string> = {
promptrequest: 'promptrequests',
promptrequests: 'promptrequests',
pr: 'promptrequests',
// PR-2: shared revision + proposal queue (replaces promptrequests in
// PR-7). Lookup goes through /api/v1/proposals and /api/v1/revisions.
proposal: 'proposals',
proposals: 'proposals',
prop: 'proposals',
revision: 'revisions',
revisions: 'revisions',
rev: 'revisions',
serverattachment: 'serverattachments',
serverattachments: 'serverattachments',
sa: 'serverattachments',

View File

@@ -23,6 +23,7 @@
"@mcpctl/shared": "workspace:*",
"@prisma/client": "^6.0.0",
"bcrypt": "^5.1.1",
"diff": "^5.2.0",
"dockerode": "^4.0.9",
"fastify": "^5.0.0",
"js-yaml": "^4.1.0",
@@ -30,6 +31,7 @@
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/diff": "^5.2.3",
"@types/dockerode": "^4.0.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.0"

View File

@@ -98,8 +98,14 @@ import {
registerMcpTokenRoutes,
} from './routes/index.js';
import { registerPromptRoutes } from './routes/prompts.js';
import { registerRevisionRoutes } from './routes/revisions.js';
import { registerProposalRoutes } from './routes/proposals.js';
import { registerGitBackupRoutes } from './routes/git-backup.js';
import { PromptService } from './services/prompt.service.js';
import { ResourceRevisionRepository } from './repositories/resource-revision.repository.js';
import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js';
import { ResourceRevisionService } from './services/resource-revision.service.js';
import { ResourceProposalService } from './services/resource-proposal.service.js';
import { GitBackupService } from './services/backup/git-backup.service.js';
import type { BackupKind } from './services/backup/yaml-serializer.js';
import { ResourceRuleRegistry } from './validation/resource-rules.js';
@@ -168,6 +174,12 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
'mcp': 'servers',
'prompts': 'prompts',
'promptrequests': 'promptrequests',
// PR-2: revisions/proposals piggyback on the prompts permission for now.
// Anyone with view:prompts can read history; anyone with edit:prompts can
// approve/reject proposals. PR-7 may split these out if RBAC granularity
// becomes useful (e.g., a "reviewer" role).
'revisions': 'prompts',
'proposals': 'prompts',
'mcptokens': 'mcptokens',
'llms': 'llms',
// v5: durable inference task queue. Same default action mapping as
@@ -468,6 +480,15 @@ async function main(): Promise<void> {
const promptRuleRegistry = new ResourceRuleRegistry();
promptRuleRegistry.register(systemPromptVarsRule);
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo);
// PR-2: shared revision/proposal infra. Promp service registers its
// 'prompt' approval handler with the proposal service via setProposalService;
// PR-3 wires the same for skills.
const resourceRevisionRepo = new ResourceRevisionRepository(prisma);
const resourceRevisionService = new ResourceRevisionService(resourceRevisionRepo);
const resourceProposalRepo = new ResourceProposalRepository(prisma);
const resourceProposalService = new ResourceProposalService(resourceProposalRepo, prisma);
promptService.setRevisionService(resourceRevisionService);
promptService.setProposalService(resourceProposalService);
const personalityRepo = new PersonalityRepository(prisma);
const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo);
const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo);
@@ -668,6 +689,8 @@ async function main(): Promise<void> {
registerGroupRoutes(app, groupService);
registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo });
registerPromptRoutes(app, promptService, projectRepo, agentRepo);
registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService });
registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo });
// ── Git-based backup ──
const gitBackup = new GitBackupService(prisma);

View File

@@ -14,6 +14,8 @@ export interface PromptUpdateInput {
priority?: number;
summary?: string;
chapters?: string[];
semver?: string;
currentRevisionId?: string | null;
}
export interface IPromptRepository {

View File

@@ -0,0 +1,138 @@
import type { PrismaClient, Prisma, ResourceProposal } from '@prisma/client';
import type { ResourceType } from './resource-revision.repository.js';
/**
* Generic propose/approve/reject queue keyed by (resourceType, name,
* projectId|agentId). Successor to PromptRequest. The repo mirrors
* PromptRepository's `?? ''` workaround for nullable-FK compound lookups.
*/
export type ProposalStatus = 'pending' | 'approved' | 'rejected';
export interface ProposalListFilter {
resourceType?: ResourceType;
projectId?: string;
agentId?: string;
status?: ProposalStatus;
}
export interface CreateProposalInput {
resourceType: ResourceType;
name: string;
body: Prisma.InputJsonValue;
projectId?: string;
agentId?: string;
createdBySession?: string;
createdByUserId?: string;
}
export interface UpdateProposalStatusInput {
status: ProposalStatus;
reviewerNote?: string;
approvedRevisionId?: string;
}
export interface IResourceProposalRepository {
list(filter: ProposalListFilter): Promise<ResourceProposal[]>;
findById(id: string): Promise<ResourceProposal | null>;
findByName(resourceType: ResourceType, name: string, scope: { projectId: string | null; agentId: string | null }): Promise<ResourceProposal | null>;
findBySession(sessionId: string, projectId?: string): Promise<ResourceProposal[]>;
create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise<ResourceProposal>;
updateBody(id: string, body: Prisma.InputJsonValue): Promise<ResourceProposal>;
updateStatus(id: string, data: UpdateProposalStatusInput, tx?: Prisma.TransactionClient): Promise<ResourceProposal>;
delete(id: string): Promise<void>;
}
export class ResourceProposalRepository implements IResourceProposalRepository {
constructor(private readonly prisma: PrismaClient) {}
async list(filter: ProposalListFilter): Promise<ResourceProposal[]> {
const where: Prisma.ResourceProposalWhereInput = {};
if (filter.resourceType) where.resourceType = filter.resourceType;
if (filter.status) where.status = filter.status;
if (filter.projectId !== undefined) {
// Match project-scoped + globals (NULL projectId), like PromptRepo.
where.OR = [{ projectId: filter.projectId }, { projectId: null, agentId: null }];
}
if (filter.agentId !== undefined) {
where.agentId = filter.agentId;
}
return this.prisma.resourceProposal.findMany({
where,
include: {
project: { select: { name: true } },
agent: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
});
}
async findById(id: string): Promise<ResourceProposal | null> {
return this.prisma.resourceProposal.findUnique({
where: { id },
include: {
project: { select: { name: true } },
agent: { select: { name: true } },
},
});
}
async findByName(
resourceType: ResourceType,
name: string,
scope: { projectId: string | null; agentId: string | null },
): Promise<ResourceProposal | null> {
if (scope.agentId !== null) {
return this.prisma.resourceProposal.findUnique({
where: { resourceType_name_agentId: { resourceType, name, agentId: scope.agentId } },
});
}
// Project-scoped or global (projectId=null is handled by the same compound key).
return this.prisma.resourceProposal.findUnique({
where: { resourceType_name_projectId: { resourceType, name, projectId: scope.projectId ?? '' } },
});
}
async findBySession(sessionId: string, projectId?: string): Promise<ResourceProposal[]> {
const where: Prisma.ResourceProposalWhereInput = { createdBySession: sessionId };
if (projectId !== undefined) {
where.OR = [{ projectId }, { projectId: null, agentId: null }];
}
return this.prisma.resourceProposal.findMany({
where,
orderBy: { createdAt: 'desc' },
});
}
async create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise<ResourceProposal> {
const client = tx ?? this.prisma;
return client.resourceProposal.create({ data });
}
async updateBody(id: string, body: Prisma.InputJsonValue): Promise<ResourceProposal> {
return this.prisma.resourceProposal.update({
where: { id },
data: { body, version: { increment: 1 } },
});
}
async updateStatus(
id: string,
data: UpdateProposalStatusInput,
tx?: Prisma.TransactionClient,
): Promise<ResourceProposal> {
const client = tx ?? this.prisma;
const update: Prisma.ResourceProposalUpdateInput = {
status: data.status,
version: { increment: 1 },
};
if (data.reviewerNote !== undefined) update.reviewerNote = data.reviewerNote;
if (data.approvedRevisionId !== undefined) update.approvedRevisionId = data.approvedRevisionId;
return client.resourceProposal.update({ where: { id }, data: update });
}
async delete(id: string): Promise<void> {
await this.prisma.resourceProposal.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,79 @@
import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client';
/**
* Append-only revision log shared by Prompt and Skill (and any future
* resource type with a `resourceType` discriminator). The repository is
* intentionally narrow: callers always know which resource they're
* looking at, so every read takes (resourceType, resourceId) explicitly.
*
* `resourceId` is a soft FK — there's no `Prompt`/`Skill` relation here,
* because revisions need to outlive the resources they describe (audit
* survives deletion). That means we accept any string and trust the
* service layer to keep them in sync with real IDs.
*/
export type ResourceType = 'prompt' | 'skill';
export interface CreateRevisionInput {
resourceType: ResourceType;
resourceId: string;
semver: string;
contentHash: string;
body: Prisma.InputJsonValue;
authorUserId?: string;
authorSessionId?: string;
note?: string;
}
export interface IResourceRevisionRepository {
create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise<ResourceRevision>;
findById(id: string): Promise<ResourceRevision | null>;
findLatest(resourceType: ResourceType, resourceId: string): Promise<ResourceRevision | null>;
findHistory(resourceType: ResourceType, resourceId: string, limit?: number): Promise<ResourceRevision[]>;
findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise<ResourceRevision | null>;
findByContentHash(contentHash: string): Promise<ResourceRevision[]>;
}
export class ResourceRevisionRepository implements IResourceRevisionRepository {
constructor(private readonly prisma: PrismaClient) {}
async create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise<ResourceRevision> {
const client = tx ?? this.prisma;
return client.resourceRevision.create({ data });
}
async findById(id: string): Promise<ResourceRevision | null> {
return this.prisma.resourceRevision.findUnique({ where: { id } });
}
async findLatest(resourceType: ResourceType, resourceId: string): Promise<ResourceRevision | null> {
return this.prisma.resourceRevision.findFirst({
where: { resourceType, resourceId },
orderBy: { createdAt: 'desc' },
});
}
async findHistory(resourceType: ResourceType, resourceId: string, limit = 100): Promise<ResourceRevision[]> {
return this.prisma.resourceRevision.findMany({
where: { resourceType, resourceId },
orderBy: { createdAt: 'desc' },
take: limit,
});
}
async findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise<ResourceRevision | null> {
// Multiple revisions can share a semver if a value was reused (rare,
// but possible with manual --semver overrides). Return the latest.
return this.prisma.resourceRevision.findFirst({
where: { resourceType, resourceId, semver },
orderBy: { createdAt: 'desc' },
});
}
async findByContentHash(contentHash: string): Promise<ResourceRevision[]> {
return this.prisma.resourceRevision.findMany({
where: { contentHash },
orderBy: { createdAt: 'desc' },
});
}
}

View File

@@ -0,0 +1,157 @@
import type { FastifyInstance } from 'fastify';
import type { ResourceProposalService } from '../services/resource-proposal.service.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
import type { IAgentRepository } from '../repositories/agent.repository.js';
import type {
ResourceType,
} from '../repositories/resource-revision.repository.js';
import type { ProposalStatus } from '../repositories/resource-proposal.repository.js';
interface ProposalRouteDeps {
proposalService: ResourceProposalService;
projectRepo: IProjectRepository;
agentRepo?: IAgentRepository;
}
const VALID_TYPES: readonly ResourceType[] = ['prompt', 'skill'] as const;
const VALID_STATUSES: readonly ProposalStatus[] = ['pending', 'approved', 'rejected'] as const;
export function registerProposalRoutes(app: FastifyInstance, deps: ProposalRouteDeps): void {
const { proposalService, projectRepo, agentRepo } = deps;
app.get<{ Querystring: { resourceType?: string; status?: string; project?: string; agent?: string } }>(
'/api/v1/proposals',
async (request) => {
const filter: {
resourceType?: ResourceType;
status?: ProposalStatus;
projectId?: string;
agentId?: string;
} = {};
const { resourceType, status, project, agent } = request.query;
if (resourceType !== undefined) {
if (!VALID_TYPES.includes(resourceType as ResourceType)) {
throw Object.assign(new Error(`Invalid resourceType: ${resourceType}`), { statusCode: 400 });
}
filter.resourceType = resourceType as ResourceType;
}
if (status !== undefined) {
if (!VALID_STATUSES.includes(status as ProposalStatus)) {
throw Object.assign(new Error(`Invalid status: ${status}`), { statusCode: 400 });
}
filter.status = status as ProposalStatus;
}
if (project !== undefined) {
const proj = await projectRepo.findByName(project);
if (proj === null) {
throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 });
}
filter.projectId = proj.id;
}
if (agent !== undefined) {
if (!agentRepo) {
throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 });
}
const ag = await agentRepo.findByName(agent);
if (ag === null) {
throw Object.assign(new Error(`Agent not found: ${agent}`), { statusCode: 404 });
}
filter.agentId = ag.id;
}
return proposalService.list(filter);
},
);
app.get<{ Params: { id: string } }>('/api/v1/proposals/:id', async (request) => {
return proposalService.getById(request.params.id);
});
app.post('/api/v1/proposals', async (request, reply) => {
const body = request.body as Record<string, unknown>;
const resourceType = body['resourceType'];
if (typeof resourceType !== 'string' || !VALID_TYPES.includes(resourceType as ResourceType)) {
throw Object.assign(new Error('resourceType must be "prompt" or "skill"'), { statusCode: 400 });
}
const name = body['name'];
if (typeof name !== 'string' || name.length === 0) {
throw Object.assign(new Error('name is required'), { statusCode: 400 });
}
const proposalBody = body['body'];
if (proposalBody === undefined || typeof proposalBody !== 'object' || proposalBody === null) {
throw Object.assign(new Error('body must be an object'), { statusCode: 400 });
}
const input: {
resourceType: ResourceType;
name: string;
body: Record<string, unknown>;
projectId?: string;
agentId?: string;
createdBySession?: string;
createdByUserId?: string;
} = {
resourceType: resourceType as ResourceType,
name,
body: proposalBody as Record<string, unknown>,
};
if (typeof body['project'] === 'string') {
const proj = await projectRepo.findByName(body['project']);
if (proj === null) {
throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 });
}
input.projectId = proj.id;
} else if (typeof body['projectId'] === 'string') {
input.projectId = body['projectId'];
}
if (typeof body['agent'] === 'string') {
if (!agentRepo) {
throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 });
}
const ag = await agentRepo.findByName(body['agent']);
if (ag === null) {
throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 });
}
input.agentId = ag.id;
} else if (typeof body['agentId'] === 'string') {
input.agentId = body['agentId'];
}
if (typeof body['createdBySession'] === 'string') input.createdBySession = body['createdBySession'];
if (typeof body['createdByUserId'] === 'string') input.createdByUserId = body['createdByUserId'];
const proposal = await proposalService.propose(input);
reply.code(201);
return proposal;
});
app.put<{ Params: { id: string }; Body: { body?: Record<string, unknown> } }>(
'/api/v1/proposals/:id',
async (request) => {
const proposalBody = request.body.body;
if (proposalBody === undefined) {
throw Object.assign(new Error('body is required'), { statusCode: 400 });
}
return proposalService.updateBody(request.params.id, proposalBody);
},
);
app.post<{ Params: { id: string } }>('/api/v1/proposals/:id/approve', async (request) => {
// approverUserId is set by the auth middleware on the request — we
// don't grab it explicitly here; service uses what the audit layer
// already records. Threading it through requires the auth context
// (out of scope for PR-2; PR-4's reviewer flow will surface it).
return proposalService.approve(request.params.id);
});
app.post<{ Params: { id: string }; Body: { reason?: string; reviewerNote?: string } }>(
'/api/v1/proposals/:id/reject',
async (request) => {
const note = request.body.reviewerNote ?? request.body.reason ?? '';
return proposalService.reject(request.params.id, note);
},
);
app.delete<{ Params: { id: string } }>('/api/v1/proposals/:id', async (request, reply) => {
await proposalService.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -0,0 +1,123 @@
import type { FastifyInstance } from 'fastify';
import { createPatch } from 'diff';
import type { ResourceRevisionService } from '../services/resource-revision.service.js';
import type { PromptService } from '../services/prompt.service.js';
import type { ResourceType } from '../repositories/resource-revision.repository.js';
interface RevisionRouteDeps {
revisionService: ResourceRevisionService;
promptService: PromptService;
// Future: skillService for PR-3.
}
const VALID_TYPES: readonly ResourceType[] = ['prompt', 'skill'] as const;
export function registerRevisionRoutes(app: FastifyInstance, deps: RevisionRouteDeps): void {
const { revisionService, promptService } = deps;
// List history for a resource. Either both query params or none (none = error).
app.get<{ Querystring: { resourceType?: string; resourceId?: string; limit?: string } }>(
'/api/v1/revisions',
async (request) => {
const { resourceType, resourceId, limit } = request.query;
if (!resourceType || !resourceId) {
throw Object.assign(
new Error('Both resourceType and resourceId are required'),
{ statusCode: 400 },
);
}
if (!VALID_TYPES.includes(resourceType as ResourceType)) {
throw Object.assign(
new Error(`Invalid resourceType: ${resourceType}`),
{ statusCode: 400 },
);
}
const limitNum = limit ? Math.min(500, Math.max(1, Number(limit))) : 100;
return revisionService.listHistory(resourceType as ResourceType, resourceId, limitNum);
},
);
app.get<{ Params: { id: string } }>('/api/v1/revisions/:id', async (request) => {
const revision = await revisionService.getById(request.params.id);
if (revision === null) {
throw Object.assign(new Error(`Revision not found: ${request.params.id}`), { statusCode: 404 });
}
return revision;
});
/**
* Unified diff between two revisions, or between a revision and the
* live resource body. `against` accepts another revision id or the
* literal string `live`.
*/
app.get<{ Params: { id: string }; Querystring: { against?: string } }>(
'/api/v1/revisions/:id/diff',
async (request) => {
const revision = await revisionService.getById(request.params.id);
if (revision === null) {
throw Object.assign(new Error(`Revision not found: ${request.params.id}`), { statusCode: 404 });
}
const against = request.query.against ?? 'live';
let otherContent: string;
let otherLabel: string;
if (against === 'live') {
// For prompts, fetch the live row by resourceId.
if (revision.resourceType === 'prompt') {
const prompt = await promptService.getPrompt(revision.resourceId);
otherContent = prompt.content;
otherLabel = `${prompt.name} (live, semver ${prompt.semver})`;
} else {
// PR-3 will wire skillService here.
throw Object.assign(
new Error(`Live diff not supported for resourceType ${revision.resourceType} yet`),
{ statusCode: 501 },
);
}
} else {
const otherRev = await revisionService.getById(against);
if (otherRev === null) {
throw Object.assign(new Error(`Other revision not found: ${against}`), { statusCode: 404 });
}
if (otherRev.resourceType !== revision.resourceType || otherRev.resourceId !== revision.resourceId) {
throw Object.assign(
new Error('Diff requires both revisions to be of the same resource'),
{ statusCode: 400 },
);
}
otherContent = stringContent(otherRev.body);
otherLabel = `revision ${otherRev.id} (${otherRev.semver})`;
}
const thisContent = stringContent(revision.body);
const thisLabel = `revision ${revision.id} (${revision.semver})`;
// Unified-format patch. Caller can render this directly or pass to a diff viewer.
const patch = createPatch(`${revision.resourceType}/${revision.resourceId}`, otherContent, thisContent, otherLabel, thisLabel);
return { patch };
},
);
// POST /api/v1/prompts/:id/restore-revision { revisionId, note? }
// (Skill route registered in PR-3 alongside this with the same shape.)
app.post<{ Params: { id: string }; Body: { revisionId: string; note?: string } }>(
'/api/v1/prompts/:id/restore-revision',
async (request) => {
const { revisionId, note } = request.body;
if (!revisionId) {
throw Object.assign(new Error('revisionId is required'), { statusCode: 400 });
}
return promptService.restoreRevisionForPrompt(request.params.id, revisionId, note);
},
);
}
/** Pull a `content` string out of a revision body, falling back to the raw JSON. */
function stringContent(body: unknown): string {
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
const v = (body as Record<string, unknown>)['content'];
if (typeof v === 'string') return v;
}
return JSON.stringify(body, null, 2);
}

View File

@@ -6,11 +6,15 @@ import type { IAgentRepository } from '../repositories/agent.repository.js';
import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js';
import { NotFoundError } from './mcp-server.service.js';
import type { PromptSummaryService } from './prompt-summary.service.js';
import type { ResourceRevisionService } from './resource-revision.service.js';
import type { ResourceProposalService } from './resource-proposal.service.js';
import { bumpSemver, type BumpKind } from '../utils/semver.js';
import { SYSTEM_PROJECT_NAME, getSystemPromptDefault } from '../bootstrap/system-project.js';
import type { ResourceRuleRegistry, RuleContext } from '../validation/resource-rules.js';
export class PromptService {
private summaryService: PromptSummaryService | null = null;
private revisionService: ResourceRevisionService | null = null;
constructor(
private readonly promptRepo: IPromptRepository,
@@ -24,6 +28,85 @@ export class PromptService {
this.summaryService = service;
}
/**
* Wire revision + proposal infrastructure (PR-2). Optional so existing
* tests that construct a bare PromptService keep working unchanged —
* when these are unset, create/update skip the revision write and
* proposal-approval is unsupported.
*/
setRevisionService(service: ResourceRevisionService): void {
this.revisionService = service;
}
setProposalService(service: ResourceProposalService): void {
// Register a 'prompt' approval handler so proposalService.approve(id)
// can dispatch to us when the proposal targets a prompt. The service
// itself is kept only via this closure binding — no field needed.
service.setHandler('prompt', async (proposal, tx, _approverUserId) => {
const body = (proposal.body ?? {}) as Record<string, unknown>;
const content = String(body['content'] ?? '');
const priority = typeof body['priority'] === 'number' ? body['priority'] : 5;
const linkTarget = typeof body['linkTarget'] === 'string' ? body['linkTarget'] : undefined;
// Resolve scope: project-only for now (agent-scoped proposals come with PR-3+).
const projectId = proposal.projectId ?? null;
const agentId = proposal.agentId ?? null;
// Upsert: existing prompt with this (name, scope) → update body and bump semver;
// otherwise → create at 0.1.0.
const existing = agentId !== null
? await tx.prompt.findUnique({ where: { name_agentId: { name: proposal.name, agentId } } })
: await tx.prompt.findUnique({ where: { name_projectId: { name: proposal.name, projectId: projectId ?? '' } } });
let promptId: string;
let newSemver: string;
if (existing !== null) {
// Bump patch for an approved-update.
newSemver = bumpSemver(existing.semver, 'patch');
await tx.prompt.update({
where: { id: existing.id },
data: { content, priority, semver: newSemver },
});
promptId = existing.id;
} else {
// Approval-from-scratch: prompt didn't exist before this proposal.
newSemver = '0.1.0';
const created = await tx.prompt.create({
data: {
name: proposal.name,
content,
priority,
...(projectId !== null ? { projectId } : {}),
...(agentId !== null ? { agentId } : {}),
...(linkTarget !== undefined ? { linkTarget } : {}),
semver: newSemver,
},
});
promptId = created.id;
}
const { revision } = await this.revisionService!.record(
{
resourceType: 'prompt',
resourceId: promptId,
semver: newSemver,
body: { content, priority, ...(linkTarget !== undefined ? { linkTarget } : {}) },
...(proposal.createdByUserId !== null ? { authorUserId: proposal.createdByUserId } : {}),
...(proposal.createdBySession !== null ? { authorSessionId: proposal.createdBySession } : {}),
note: `approved proposal ${proposal.id}`,
},
tx,
);
// Soft pointer to latest revision.
await tx.prompt.update({
where: { id: promptId },
data: { currentRevisionId: revision.id },
});
return { resourceId: promptId, revisionId: revision.id };
});
}
/**
* Run resource validation rules for a prompt.
* Throws 400 if validation fails.
@@ -104,6 +187,10 @@ export class PromptService {
if (data.priority !== undefined) createData.priority = data.priority;
if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget;
const prompt = await this.promptRepo.create(createData);
// Record initial revision (0.1.0). Non-blocking — revision is audit, not source of truth.
if (this.revisionService) {
this.recordPromptRevision(prompt, '0.1.0', 'created').catch(() => {});
}
// Auto-generate summary/chapters (non-blocking — don't fail create if summary fails)
if (this.summaryService && !data.linkTarget) {
this.generateAndStoreSummary(prompt.id, data.content).catch(() => {});
@@ -113,16 +200,38 @@ export class PromptService {
async updatePrompt(id: string, input: unknown): Promise<Prompt> {
const data = UpdatePromptSchema.parse(input);
if (data.semver !== undefined && data.bump !== undefined) {
throw Object.assign(new Error('Pass --semver or --bump, not both'), { statusCode: 400 });
}
const existing = await this.getPrompt(id);
if (data.content !== undefined) {
await this.validatePromptRules(existing.name, data.content, existing.projectId, 'update');
}
const updateData: { content?: string; priority?: number } = {};
// Resolve new semver:
// explicit > explicit-bump > auto-patch (only when content changed)
let newSemver = existing.semver;
if (data.semver !== undefined) {
newSemver = data.semver;
} else if (data.bump !== undefined) {
newSemver = bumpSemver(existing.semver, data.bump as BumpKind);
} else if (data.content !== undefined) {
newSemver = bumpSemver(existing.semver, 'patch');
}
const updateData: { content?: string; priority?: number; semver?: string } = {};
if (data.content !== undefined) updateData.content = data.content;
if (data.priority !== undefined) updateData.priority = data.priority;
if (newSemver !== existing.semver) updateData.semver = newSemver;
const prompt = await this.promptRepo.update(id, updateData);
// Record revision when content actually changed OR semver was explicitly bumped.
const shouldRecord = data.content !== undefined || data.bump !== undefined || data.semver !== undefined;
if (this.revisionService && shouldRecord) {
this.recordPromptRevision(prompt, newSemver, data.note ?? null).catch(() => {});
}
// Regenerate summary when content changes
if (this.summaryService && data.content !== undefined && !prompt.linkTarget) {
this.generateAndStoreSummary(prompt.id, data.content).catch(() => {});
@@ -130,6 +239,57 @@ export class PromptService {
return prompt;
}
/**
* Append a ResourceRevision row for this prompt and update its
* currentRevisionId. Best-effort — failures are swallowed because the
* audit log isn't load-bearing (the resource row's inline content is
* the source of truth).
*/
private async recordPromptRevision(prompt: Prompt, semver: string, note: string | null): Promise<void> {
if (this.revisionService === null) return;
const body: Record<string, unknown> = { content: prompt.content, priority: prompt.priority };
if (prompt.linkTarget !== null) body['linkTarget'] = prompt.linkTarget;
const { revision } = await this.revisionService.record({
resourceType: 'prompt',
resourceId: prompt.id,
semver,
body,
...(note !== null ? { note } : {}),
});
await this.promptRepo.update(prompt.id, { currentRevisionId: revision.id });
}
/**
* Restore a prompt to a prior revision: writes the revision's body
* back as a NEW update (which produces a new patch-bumped revision),
* preserving the audit chain. Returns the updated prompt.
*/
async restoreRevisionForPrompt(promptId: string, revisionId: string, note?: string): Promise<Prompt> {
if (this.revisionService === null) {
throw new Error('Revision service not wired');
}
const revision = await this.revisionService.getById(revisionId);
if (revision === null) throw new NotFoundError(`Revision not found: ${revisionId}`);
if (revision.resourceType !== 'prompt' || revision.resourceId !== promptId) {
throw Object.assign(
new Error('Revision does not belong to this prompt'),
{ statusCode: 400 },
);
}
const body = (revision.body ?? {}) as Record<string, unknown>;
const content = typeof body['content'] === 'string' ? body['content'] : undefined;
const priority = typeof body['priority'] === 'number' ? body['priority'] : undefined;
if (content === undefined) {
throw Object.assign(new Error('Revision has no content to restore'), { statusCode: 400 });
}
return this.updatePrompt(promptId, {
content,
priority,
bump: 'patch',
note: note ?? `restored from revision ${revisionId}`,
});
}
async regenerateSummary(id: string): Promise<Prompt> {
const prompt = await this.getPrompt(id);
if (!this.summaryService) {
@@ -226,6 +386,11 @@ export class PromptService {
const prompt = await this.promptRepo.create(createData);
// Record the initial revision so the approved prompt has a v0.1.0 history entry.
if (this.revisionService) {
this.recordPromptRevision(prompt, '0.1.0', `approved promptrequest ${requestId}`).catch(() => {});
}
// Delete the request
await this.promptRequestRepo.delete(requestId);
@@ -324,3 +489,4 @@ export class PromptService {
return results;
}
}

View File

@@ -0,0 +1,133 @@
import type { PrismaClient, Prisma, ResourceProposal } from '@prisma/client';
import type {
IResourceProposalRepository,
CreateProposalInput,
ProposalListFilter,
} from '../repositories/resource-proposal.repository.js';
import type { ResourceType } from '../repositories/resource-revision.repository.js';
import { NotFoundError } from './mcp-server.service.js';
/**
* Per-resourceType handler invoked when a proposal is approved. The
* handler runs inside the approval transaction; it must apply the
* proposed body to the live resource (creating it if needed), record
* a ResourceRevision, and return the resulting revision id so the
* proposal row can link to it.
*
* Registered by the resource's own service at boot time:
* PromptService → setHandler('prompt', ...)
* SkillService → setHandler('skill', ...) // PR-3
*/
export type ProposalApprovalHandler = (
proposal: ResourceProposal,
tx: Prisma.TransactionClient,
approverUserId?: string,
) => Promise<{ resourceId: string; revisionId: string }>;
export interface ProposeInput {
resourceType: ResourceType;
name: string;
body: Record<string, unknown>;
projectId?: string;
agentId?: string;
createdBySession?: string;
createdByUserId?: string;
}
export class ResourceProposalService {
private readonly handlers = new Map<ResourceType, ProposalApprovalHandler>();
constructor(
private readonly repo: IResourceProposalRepository,
private readonly prisma: PrismaClient,
) {}
/** Registered by Prompt/Skill services at construction time. */
setHandler(resourceType: ResourceType, handler: ProposalApprovalHandler): void {
this.handlers.set(resourceType, handler);
}
async list(filter: ProposalListFilter): Promise<ResourceProposal[]> {
return this.repo.list(filter);
}
async getById(id: string): Promise<ResourceProposal> {
const proposal = await this.repo.findById(id);
if (proposal === null) throw new NotFoundError(`Proposal not found: ${id}`);
return proposal;
}
async findBySession(sessionId: string, projectId?: string): Promise<ResourceProposal[]> {
return this.repo.findBySession(sessionId, projectId);
}
async propose(input: ProposeInput): Promise<ResourceProposal> {
const data: CreateProposalInput = {
resourceType: input.resourceType,
name: input.name,
body: input.body as Prisma.InputJsonValue,
};
if (input.projectId !== undefined) data.projectId = input.projectId;
if (input.agentId !== undefined) data.agentId = input.agentId;
if (input.createdBySession !== undefined) data.createdBySession = input.createdBySession;
if (input.createdByUserId !== undefined) data.createdByUserId = input.createdByUserId;
return this.repo.create(data);
}
async updateBody(id: string, body: Record<string, unknown>): Promise<ResourceProposal> {
await this.getById(id); // 404 if missing
return this.repo.updateBody(id, body as Prisma.InputJsonValue);
}
/**
* Approve the proposal: dispatch to the type-specific handler inside
* a transaction, then mark the proposal `approved` and link the
* resulting revision id.
*/
async approve(id: string, approverUserId?: string): Promise<ResourceProposal> {
return this.prisma.$transaction(async (tx) => {
const proposal = await tx.resourceProposal.findUnique({ where: { id } });
if (proposal === null) throw new NotFoundError(`Proposal not found: ${id}`);
if (proposal.status !== 'pending') {
throw Object.assign(
new Error(`Proposal is ${proposal.status}, not pending`),
{ statusCode: 409 },
);
}
const handler = this.handlers.get(proposal.resourceType as ResourceType);
if (handler === undefined) {
throw Object.assign(
new Error(`No approval handler registered for resource type: ${proposal.resourceType}`),
{ statusCode: 500 },
);
}
const { revisionId } = await handler(proposal, tx, approverUserId);
return tx.resourceProposal.update({
where: { id },
data: {
status: 'approved',
approvedRevisionId: revisionId,
version: { increment: 1 },
...(approverUserId !== undefined ? {} : {}),
},
});
});
}
async reject(id: string, reviewerNote: string, _reviewerUserId?: string): Promise<ResourceProposal> {
const proposal = await this.getById(id);
if (proposal.status !== 'pending') {
throw Object.assign(
new Error(`Proposal is ${proposal.status}, not pending`),
{ statusCode: 409 },
);
}
return this.repo.updateStatus(id, { status: 'rejected', reviewerNote });
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.repo.delete(id);
}
}

View File

@@ -0,0 +1,95 @@
import crypto from 'node:crypto';
import type { Prisma, ResourceRevision } from '@prisma/client';
import type {
IResourceRevisionRepository,
ResourceType,
} from '../repositories/resource-revision.repository.js';
export interface RecordRevisionInput {
resourceType: ResourceType;
resourceId: string;
/** New semver — caller computes via bumpSemver / explicit override. */
semver: string;
/**
* Snapshot of the resource body at this revision. Shape is
* resource-specific — for Prompt: `{ content, priority, linkTarget }`;
* for Skill: `{ content, files, metadata, priority, description }`.
* Stored as-is in `body` (jsonb) and used as the diff/restore source
* by the revisions API.
*/
body: Record<string, unknown>;
authorUserId?: string;
authorSessionId?: string;
note?: string;
}
export class ResourceRevisionService {
constructor(private readonly repo: IResourceRevisionRepository) {}
/**
* sha256 of the canonicalised body. Stable across key reorderings so a
* resource that's saved twice with the same logical content produces
* the same hash on both revisions — useful for sync-side dedup.
*/
static hash(body: unknown): string {
return 'sha256:' + crypto.createHash('sha256').update(canonicalJson(body)).digest('hex');
}
async record(
input: RecordRevisionInput,
tx?: Prisma.TransactionClient,
): Promise<{ revision: ResourceRevision; contentHash: string }> {
const contentHash = ResourceRevisionService.hash(input.body);
const revision = await this.repo.create(
{
resourceType: input.resourceType,
resourceId: input.resourceId,
semver: input.semver,
contentHash,
body: input.body as Prisma.InputJsonValue,
...(input.authorUserId !== undefined ? { authorUserId: input.authorUserId } : {}),
...(input.authorSessionId !== undefined ? { authorSessionId: input.authorSessionId } : {}),
...(input.note !== undefined ? { note: input.note } : {}),
},
tx,
);
return { revision, contentHash };
}
async getById(id: string): Promise<ResourceRevision | null> {
return this.repo.findById(id);
}
async listHistory(
resourceType: ResourceType,
resourceId: string,
limit?: number,
): Promise<ResourceRevision[]> {
return this.repo.findHistory(resourceType, resourceId, limit);
}
async findBySemver(
resourceType: ResourceType,
resourceId: string,
semver: string,
): Promise<ResourceRevision | null> {
return this.repo.findBySemver(resourceType, resourceId, semver);
}
}
/**
* Canonical JSON: keys sorted at every object level. Used by `hash` so
* `{a:1,b:2}` and `{b:2,a:1}` produce the same digest.
*/
function canonicalJson(v: unknown): string {
if (v === null || v === undefined || typeof v !== 'object') {
return JSON.stringify(v ?? null);
}
if (Array.isArray(v)) {
return '[' + v.map(canonicalJson).join(',') + ']';
}
const obj = v as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}';
}

View File

@@ -0,0 +1,56 @@
/**
* Tiny semver bumper for resource versions. mcpctl is the source of truth
* for prompts and skills; their versions are advisory rather than
* dependency-resolved, so we don't need a full semver library — just patch
* `0.1.0` → `0.1.1` on every save and let authors bump major/minor when
* something material changes.
*
* Anything that isn't a strict `MAJOR.MINOR.PATCH` (digits-only, three
* parts) is treated as invalid and replaced with `'0.1.0'`. We don't
* support pre-release / build-metadata suffixes for resources; if that
* ever becomes useful we can swap in `semver` from npm without changing
* call sites.
*/
const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/;
export type BumpKind = 'major' | 'minor' | 'patch';
export function isValidSemver(s: string): boolean {
return SEMVER_RE.test(s);
}
export function bumpSemver(current: string, kind: BumpKind): string {
const m = SEMVER_RE.exec(current);
if (m === null) {
// Caller passed something we can't parse — start over rather than
// silently corrupt. Prefer this to throwing because the call path
// (PromptService.update) would then propagate failure across the
// entire transaction including the body update.
return '0.1.0';
}
const major = Number(m[1]);
const minor = Number(m[2]);
const patch = Number(m[3]);
switch (kind) {
case 'major':
return `${String(major + 1)}.0.0`;
case 'minor':
return `${String(major)}.${String(minor + 1)}.0`;
case 'patch':
return `${String(major)}.${String(minor)}.${String(patch + 1)}`;
}
}
/** Compare a < b: returns -1, 0, +1 by major/minor/patch. Invalid → 0. */
export function compareSemver(a: string, b: string): number {
const ma = SEMVER_RE.exec(a);
const mb = SEMVER_RE.exec(b);
if (ma === null || mb === null) return 0;
for (let i = 1; i <= 3; i++) {
const ai = Number(ma[i]);
const bi = Number(mb[i]);
if (ai !== bi) return ai < bi ? -1 : 1;
}
return 0;
}

View File

@@ -16,9 +16,18 @@ export const CreatePromptSchema = z
{ message: 'A prompt may attach to a project XOR an agent, not both', path: ['agentId'] },
);
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
export const UpdatePromptSchema = z.object({
content: z.string().min(1).max(50000).optional(),
priority: z.number().int().min(1).max(10).optional(),
// Semver controls (PR-2). At most one of `semver` and `bump` may be
// set; service layer rejects both. If neither is set, content changes
// auto-bump patch.
semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(),
bump: z.enum(['major', 'minor', 'patch']).optional(),
// Free-form note attached to the resulting ResourceRevision row.
note: z.string().max(500).optional(),
// linkTarget intentionally excluded — links are immutable
});

View File

@@ -17,10 +17,13 @@ function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
name: 'test-prompt',
content: 'Hello world',
projectId: null,
agentId: null,
priority: 5,
summary: null,
chapters: null,
linkTarget: null,
semver: '0.1.0',
currentRevisionId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -316,9 +319,11 @@ describe('Prompt routes', () => {
payload: { content: 'new content', projectId: 'proj-evil' },
});
// Should succeed but ignore projectId — UpdatePromptSchema strips it
// Should succeed but ignore projectId — UpdatePromptSchema strips it.
// PR-2: a content change auto-bumps the patch number, so the update
// call also carries the new semver.
expect(res.statusCode).toBe(200);
expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content' });
expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content', semver: '0.1.1' });
// projectId must NOT be in the update call
const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1];
expect(updateArg).not.toHaveProperty('projectId');

View File

@@ -11,10 +11,13 @@ function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
name: 'test-prompt',
content: 'Hello world',
projectId: null,
agentId: null,
priority: 5,
summary: null,
chapters: null,
linkTarget: null,
semver: '0.1.0',
currentRevisionId: null,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
@@ -175,7 +178,9 @@ describe('PromptService', () => {
it('should update prompt content', async () => {
vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt());
await service.updatePrompt('prompt-1', { content: 'updated' });
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated' });
// Auto-patch bump on content change (PR-2): updatePrompt now also
// emits the new semver in the same update call.
expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated', semver: '0.1.1' });
});
it('should throw for missing prompt', async () => {

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { bumpSemver, compareSemver, isValidSemver } from '../../src/utils/semver.js';
describe('bumpSemver', () => {
it('bumps patch', () => {
expect(bumpSemver('0.1.0', 'patch')).toBe('0.1.1');
expect(bumpSemver('1.2.3', 'patch')).toBe('1.2.4');
});
it('bumps minor and resets patch', () => {
expect(bumpSemver('0.1.5', 'minor')).toBe('0.2.0');
expect(bumpSemver('1.2.3', 'minor')).toBe('1.3.0');
});
it('bumps major and resets minor + patch', () => {
expect(bumpSemver('0.1.5', 'major')).toBe('1.0.0');
expect(bumpSemver('1.2.3', 'major')).toBe('2.0.0');
});
it('falls back to 0.1.0 on invalid input', () => {
expect(bumpSemver('not-a-semver', 'patch')).toBe('0.1.0');
expect(bumpSemver('1.0', 'patch')).toBe('0.1.0');
expect(bumpSemver('1.0.0-beta', 'patch')).toBe('0.1.0');
expect(bumpSemver('', 'patch')).toBe('0.1.0');
});
});
describe('compareSemver', () => {
it('returns 0 for equal', () => {
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
});
it('returns -1 when a < b at any field', () => {
expect(compareSemver('1.2.3', '1.2.4')).toBe(-1);
expect(compareSemver('1.2.3', '1.3.0')).toBe(-1);
expect(compareSemver('1.2.3', '2.0.0')).toBe(-1);
});
it('returns +1 when a > b at any field', () => {
expect(compareSemver('1.2.4', '1.2.3')).toBe(1);
expect(compareSemver('1.3.0', '1.2.3')).toBe(1);
expect(compareSemver('2.0.0', '1.2.3')).toBe(1);
});
it('compares numerically (10 > 9, not lex)', () => {
expect(compareSemver('0.10.0', '0.9.0')).toBe(1);
expect(compareSemver('0.9.0', '0.10.0')).toBe(-1);
});
it('returns 0 for invalid input rather than throwing', () => {
expect(compareSemver('bad', '1.0.0')).toBe(0);
expect(compareSemver('1.0.0', 'bad')).toBe(0);
});
});
describe('isValidSemver', () => {
it('accepts MAJOR.MINOR.PATCH digits', () => {
expect(isValidSemver('0.0.0')).toBe(true);
expect(isValidSemver('1.2.3')).toBe(true);
expect(isValidSemver('999.999.999')).toBe(true);
});
it('rejects everything else', () => {
expect(isValidSemver('1.2')).toBe(false);
expect(isValidSemver('1.2.3.4')).toBe(false);
expect(isValidSemver('v1.2.3')).toBe(false);
expect(isValidSemver('1.2.3-beta')).toBe(false);
expect(isValidSemver('')).toBe(false);
});
});