import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; import os from "node:os"; import { getSkillRegistryDb, resetSkillRegistryDb } from "../services/skill-registry-db.js"; import { skills, skillVersions, skillFiles } from "../services/skill-registry-schema.js"; import { skillRegistryService } from "../services/skill-registry.js"; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- let tmpHome: string; let tmpAgentSkillsDir: string; beforeEach(async () => { // Create isolated temp dirs tmpHome = await mkdtemp(path.join(os.tmpdir(), "nexus-skill-registry-test-")); tmpAgentSkillsDir = path.join(tmpHome, "agent-skills"); await mkdir(tmpAgentSkillsDir, { recursive: true }); // Point PAPERCLIP_HOME at temp dir for DB and cache process.env.PAPERCLIP_HOME = tmpHome; resetSkillRegistryDb(); }); afterEach(async () => { resetSkillRegistryDb(); delete process.env.PAPERCLIP_HOME; await rm(tmpHome, { recursive: true, force: true }); }); // Seed helpers async function seedSkillWithVersion(opts: { skillId: string; sourceId: string; versionId: string; cacheDir?: string; fileKind?: string; }): Promise { const db = await getSkillRegistryDb(); const now = Date.now(); await db.insert(skills).values({ id: opts.skillId, sourceId: opts.sourceId, name: "Test Skill", description: "A test skill", sourceUrl: `https://github.com/test/${opts.skillId}`, activeVersionId: null, removedAt: null, createdAt: now, updatedAt: now, }); await db.insert(skillVersions).values({ id: opts.versionId, skillId: opts.skillId, version: "abc123", fetchedAt: now, cacheDir: opts.cacheDir ?? null, }); await db.insert(skillFiles).values({ id: `file-${opts.versionId}`, versionId: opts.versionId, path: "SKILL.md", kind: opts.fileKind ?? "skill", sizeBytes: 100, }); } async function createFakeCacheDir(cacheDir: string): Promise { await mkdir(cacheDir, { recursive: true }); await writeFile(path.join(cacheDir, "SKILL.md"), "# Test Skill\n\nContent.", "utf-8"); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("skillRegistryService", () => { const svc = skillRegistryService(); describe("install — SKILL.md-based skill", () => { it("Test 1: copies files from cache dir to agentSkillsDir//", async () => { const skillId = "schwepps-skills/code-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir }); const result = await svc.install(skillId, tmpAgentSkillsDir); expect(result.type).toBe("installed"); if (result.type === "installed") { const slug = "code-review"; const expectedTarget = path.join(tmpAgentSkillsDir, slug); expect(result.targetDir).toBe(expectedTarget); expect(existsSync(path.join(expectedTarget, "SKILL.md"))).toBe(true); } }); it("Test 2: updates skill active_version_id to the latest version", async () => { const skillId = "schwepps-skills/code-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir }); await svc.install(skillId, tmpAgentSkillsDir); const db = await getSkillRegistryDb(); const rows = await db.select().from(skills).where( (await import("drizzle-orm")).eq(skills.id, skillId) ); expect(rows[0]?.activeVersionId).toBe(versionId); }); }); describe("install — marketplace plugin", () => { it("Test 3: returns pending_plugin_install command instead of copying files for plugin kind", async () => { const skillId = "anthropic-official/my-plugin"; const versionId = `${skillId}@deadbeef`; const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "deadbeef"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "anthropic-official", versionId, cacheDir, fileKind: "plugin", }); const result = await svc.install(skillId, tmpAgentSkillsDir); expect(result.type).toBe("pending_plugin_install"); if (result.type === "pending_plugin_install") { expect(result.command).toContain("/plugin install"); expect(result.skillId).toBe(skillId); expect(result.versionId).toBe(versionId); } // No files should be copied to agent dir const agentFiles = readdirSync(tmpAgentSkillsDir); expect(agentFiles).toHaveLength(0); }); }); describe("uninstall", () => { it("Test 4: sets removed_at timestamp on skills row", async () => { const skillId = "schwepps-skills/code-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir }); const before = Date.now(); await svc.uninstall(skillId, tmpAgentSkillsDir); const after = Date.now(); const db = await getSkillRegistryDb(); const { eq } = await import("drizzle-orm"); const rows = await db.select().from(skills).where(eq(skills.id, skillId)); expect(rows[0]?.removedAt).toBeGreaterThanOrEqual(before); expect(rows[0]?.removedAt).toBeLessThanOrEqual(after); }); it("Test 5: row still exists and is returned with includeRemoved=true", async () => { const skillId = "schwepps-skills/code-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "skills", "cache", skillId, "abc123"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "schwepps-skills", versionId, cacheDir }); await svc.uninstall(skillId, tmpAgentSkillsDir); // Not visible in normal list const normalList = await svc.list(); expect(normalList.find((s) => s.id === skillId)).toBeUndefined(); // Visible with includeRemoved const fullList = await svc.list({ includeRemoved: true }); expect(fullList.find((s) => s.id === skillId)).toBeDefined(); }); }); describe("rollback", () => { it("Test 6: copies prior version's cached files to agent skills dir", async () => { const skillId = "schwepps-skills/code-review"; const slug = "code-review"; // Seed v1 (prior) and v2 (current) const v1Id = `${skillId}@v1sha`; const v2Id = `${skillId}@v2sha`; const v1CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v1sha"); const v2CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v2sha"); await createFakeCacheDir(v1CacheDir); await writeFile(path.join(v1CacheDir, "SKILL.md"), "# Version 1", "utf-8"); await createFakeCacheDir(v2CacheDir); await writeFile(path.join(v2CacheDir, "SKILL.md"), "# Version 2", "utf-8"); // Seed both versions const db = await getSkillRegistryDb(); const now = Date.now(); await db.insert(skills).values({ id: skillId, sourceId: "schwepps-skills", name: "Test", description: null, sourceUrl: "https://github.com/test", activeVersionId: v2Id, removedAt: null, createdAt: now, updatedAt: now, }); await db.insert(skillVersions).values({ id: v1Id, skillId, version: "v1sha", fetchedAt: now - 1000, cacheDir: v1CacheDir, }); await db.insert(skillVersions).values({ id: v2Id, skillId, version: "v2sha", fetchedAt: now, cacheDir: v2CacheDir, }); await db.insert(skillFiles).values({ id: "file-v1", versionId: v1Id, path: "SKILL.md", kind: "skill", sizeBytes: 12, }); await db.insert(skillFiles).values({ id: "file-v2", versionId: v2Id, path: "SKILL.md", kind: "skill", sizeBytes: 12, }); // Install v2 first const targetDir = path.join(tmpAgentSkillsDir, slug); await mkdir(targetDir, { recursive: true }); await writeFile(path.join(targetDir, "SKILL.md"), "# Version 2", "utf-8"); // Rollback to v1 await svc.rollback(skillId, v1Id, tmpAgentSkillsDir); // Verify v1 content is in place const { readFileSync } = await import("node:fs"); const content = readFileSync(path.join(targetDir, "SKILL.md"), "utf-8"); expect(content).toBe("# Version 1"); }); it("Test 7: updates active_version_id to the prior version", async () => { const skillId = "schwepps-skills/code-review"; const v1Id = `${skillId}@v1sha`; const v2Id = `${skillId}@v2sha`; const v1CacheDir = path.join(tmpHome, "skills", "cache", skillId, "v1sha"); await createFakeCacheDir(v1CacheDir); const db = await getSkillRegistryDb(); const now = Date.now(); await db.insert(skills).values({ id: skillId, sourceId: "test", name: "T", description: null, sourceUrl: "u", activeVersionId: v2Id, removedAt: null, createdAt: now, updatedAt: now, }); await db.insert(skillVersions).values({ id: v1Id, skillId, version: "v1sha", fetchedAt: now - 1000, cacheDir: v1CacheDir, }); const agentDir = path.join(tmpAgentSkillsDir, "code-review"); await mkdir(agentDir, { recursive: true }); await writeFile(path.join(agentDir, "SKILL.md"), "current", "utf-8"); await svc.rollback(skillId, v1Id, tmpAgentSkillsDir); const { eq } = await import("drizzle-orm"); const rows = await db.select().from(skills).where(eq(skills.id, skillId)); expect(rows[0]?.activeVersionId).toBe(v1Id); }); }); describe("list", () => { it("Test 8: returns only skills where removed_at IS NULL by default", async () => { const db = await getSkillRegistryDb(); const now = Date.now(); await db.insert(skills).values([ { id: "active-skill", sourceId: "test", name: "Active", description: null, sourceUrl: "u", activeVersionId: null, removedAt: null, createdAt: now, updatedAt: now, }, { id: "removed-skill", sourceId: "test", name: "Removed", description: null, sourceUrl: "u", activeVersionId: null, removedAt: now - 1000, createdAt: now, updatedAt: now, }, ]); const result = await svc.list(); const ids = result.map((s) => s.id); expect(ids).toContain("active-skill"); expect(ids).not.toContain("removed-skill"); }); it("Test 9: list({ includeRemoved: true }) returns all skills", async () => { const db = await getSkillRegistryDb(); const now = Date.now(); await db.insert(skills).values([ { id: "active-skill", sourceId: "test", name: "Active", description: null, sourceUrl: "u", activeVersionId: null, removedAt: null, createdAt: now, updatedAt: now, }, { id: "removed-skill", sourceId: "test", name: "Removed", description: null, sourceUrl: "u", activeVersionId: null, removedAt: now - 1000, createdAt: now, updatedAt: now, }, ]); const result = await svc.list({ includeRemoved: true }); const ids = result.map((s) => s.id); expect(ids).toContain("active-skill"); expect(ids).toContain("removed-skill"); }); }); });