/** * Tests for adapter-aware install/uninstall/rollback/assignGroup/removeGroup. * Verifies that all file operations use the caller-supplied agentSkillsDir * rather than any hardcoded path. * * Covers: INST-01, INST-02, INST-03, INST-04 */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm, mkdir, writeFile, readdir } from "node:fs/promises"; import { existsSync } 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, agentSkills } from "../services/skill-registry-schema.js"; import { skillRegistryService } from "../services/skill-registry.js"; import { skillGroupService } from "../services/skill-registry-groups.js"; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- let tmpHome: string; let tmpAgentSkillsDir: string; beforeEach(async () => { tmpHome = await mkdtemp(path.join(os.tmpdir(), "nexus-adapter-install-test-")); tmpAgentSkillsDir = path.join(tmpHome, "agent-skills"); await mkdir(tmpAgentSkillsDir, { recursive: true }); process.env.PAPERCLIP_HOME = tmpHome; resetSkillRegistryDb(); }); afterEach(async () => { resetSkillRegistryDb(); delete process.env.PAPERCLIP_HOME; await rm(tmpHome, { recursive: true, force: true }); }); 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: null, sourceUrl: null, 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, content = "# Test Skill"): Promise { await mkdir(cacheDir, { recursive: true }); await writeFile(path.join(cacheDir, "SKILL.md"), content, "utf-8"); } // --------------------------------------------------------------------------- // install() — writes to provided agentSkillsDir // --------------------------------------------------------------------------- describe("install() — adapter-aware path", () => { it("Test 1: writes skill files to the provided agentSkillsDir, not a hardcoded path", async () => { const skillId = "nexus-skills/typescript-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "cache", "typescript-review"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "nexus-skills", versionId, cacheDir }); const svc = skillRegistryService(); const result = await svc.install(skillId, tmpAgentSkillsDir); expect(result.type).toBe("installed"); if (result.type === "installed") { // Files must land inside tmpAgentSkillsDir, not any hardcoded path expect(result.targetDir).toContain(tmpAgentSkillsDir); expect(existsSync(path.join(result.targetDir, "SKILL.md"))).toBe(true); } }); it("Test 2: slug (last segment of skillId) is used as subdirectory name", async () => { const skillId = "nexus-skills/code-review"; const versionId = `${skillId}@sha1`; const cacheDir = path.join(tmpHome, "cache", "code-review"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "nexus-skills", versionId, cacheDir }); const svc = skillRegistryService(); const result = await svc.install(skillId, tmpAgentSkillsDir); expect(result.type).toBe("installed"); if (result.type === "installed") { expect(result.targetDir).toBe(path.join(tmpAgentSkillsDir, "code-review")); } }); }); // --------------------------------------------------------------------------- // uninstall() — removes files AND soft-deletes DB row // --------------------------------------------------------------------------- describe("uninstall() — file removal + soft-delete", () => { it("Test 3: removes skill directory from disk", async () => { const skillId = "nexus-skills/code-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "cache", "code-review"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "nexus-skills", versionId, cacheDir }); const svc = skillRegistryService(); // First install so files are on disk await svc.install(skillId, tmpAgentSkillsDir); const targetDir = path.join(tmpAgentSkillsDir, "code-review"); expect(existsSync(targetDir)).toBe(true); // Now uninstall — should remove files await svc.uninstall(skillId, tmpAgentSkillsDir); expect(existsSync(targetDir)).toBe(false); }); it("Test 4: soft-deletes the DB row (sets removedAt)", async () => { const skillId = "nexus-skills/code-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "cache", "code-review"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "nexus-skills", versionId, cacheDir }); const svc = skillRegistryService(); 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: uninstall with non-existent directory does not throw (force: true)", async () => { const skillId = "nexus-skills/missing-skill"; await seedSkillWithVersion({ skillId, sourceId: "nexus-skills", versionId: `${skillId}@sha1`, }); const svc = skillRegistryService(); // targetDir never existed — should not throw await expect(svc.uninstall(skillId, tmpAgentSkillsDir)).resolves.toBeUndefined(); }); it("Test 6: uninstall removes files from the correct path (slug-based subdirectory)", async () => { const skillId = "my-source/my-skill-name"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "cache", "my-skill-name"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "my-source", versionId, cacheDir }); const svc = skillRegistryService(); await svc.install(skillId, tmpAgentSkillsDir); // Confirm the correct subdirectory was created const expectedDir = path.join(tmpAgentSkillsDir, "my-skill-name"); expect(existsSync(expectedDir)).toBe(true); await svc.uninstall(skillId, tmpAgentSkillsDir); expect(existsSync(expectedDir)).toBe(false); }); }); // --------------------------------------------------------------------------- // rollback() — restores files to provided agentSkillsDir // --------------------------------------------------------------------------- describe("rollback() — restores to provided agentSkillsDir", () => { it("Test 7: restores files to the provided agentSkillsDir (not a hardcoded path)", async () => { const skillId = "nexus-skills/code-review"; const v1Id = `${skillId}@v1`; const v2Id = `${skillId}@v2`; const v1CacheDir = path.join(tmpHome, "cache", skillId, "v1"); const v2CacheDir = path.join(tmpHome, "cache", skillId, "v2"); await createFakeCacheDir(v1CacheDir, "# Version 1"); await createFakeCacheDir(v2CacheDir, "# Version 2"); const db = await getSkillRegistryDb(); const now = Date.now(); await db.insert(skills).values({ id: skillId, sourceId: "nexus-skills", name: "Code Review", description: null, sourceUrl: null, activeVersionId: v2Id, removedAt: null, createdAt: now, updatedAt: now, }); await db.insert(skillVersions).values({ id: v1Id, skillId, version: "v1", fetchedAt: now - 1000, cacheDir: v1CacheDir }); await db.insert(skillVersions).values({ id: v2Id, skillId, version: "v2", fetchedAt: now, cacheDir: v2CacheDir }); await db.insert(skillFiles).values({ id: "f1", versionId: v1Id, path: "SKILL.md", kind: "skill", sizeBytes: 12 }); await db.insert(skillFiles).values({ id: "f2", versionId: v2Id, path: "SKILL.md", kind: "skill", sizeBytes: 12 }); // Pre-install v2 const targetDir = path.join(tmpAgentSkillsDir, "code-review"); await mkdir(targetDir, { recursive: true }); await writeFile(path.join(targetDir, "SKILL.md"), "# Version 2", "utf-8"); // Rollback to v1 using provided agentSkillsDir const svc = skillRegistryService(); await svc.rollback(skillId, v1Id, tmpAgentSkillsDir); // Files must be in tmpAgentSkillsDir, not any hardcoded path const { readFileSync } = await import("node:fs"); const content = readFileSync(path.join(tmpAgentSkillsDir, "code-review", "SKILL.md"), "utf-8"); expect(content).toBe("# Version 1"); }); }); // --------------------------------------------------------------------------- // assignGroup() / removeGroup() — use provided agentSkillsDir // --------------------------------------------------------------------------- describe("assignGroup() — uses provided agentSkillsDir", () => { it("Test 8: assignGroup installs skills into the provided agentSkillsDir", async () => { const skillId = "nexus-skills/code-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "cache", "code-review"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "nexus-skills", versionId, cacheDir }); const db = await getSkillRegistryDb(); const now = Date.now(); const groupId = "custom/test-group"; await db.insert((await import("../services/skill-registry-schema.js")).skillGroups).values({ id: groupId, name: "Test Group", description: null, isBuiltin: 0, createdAt: now, updatedAt: now, }); await db.insert((await import("../services/skill-registry-schema.js")).skillGroupMembers).values({ groupId, skillId, addedAt: now, }); const grpSvc = skillGroupService(); const result = await grpSvc.assignGroup(groupId, "agent-123", tmpAgentSkillsDir); // The skill should have been installed into our provided dir expect(result.installed).toContain(skillId); expect(existsSync(path.join(tmpAgentSkillsDir, "code-review", "SKILL.md"))).toBe(true); }); }); describe("removeGroup() — uses provided agentSkillsDir", () => { it("Test 9: removeGroup removes skill files from the provided agentSkillsDir", async () => { const skillId = "nexus-skills/code-review"; const versionId = `${skillId}@abc123`; const cacheDir = path.join(tmpHome, "cache", "code-review"); await createFakeCacheDir(cacheDir); await seedSkillWithVersion({ skillId, sourceId: "nexus-skills", versionId, cacheDir }); const db = await getSkillRegistryDb(); const now = Date.now(); const groupId = "custom/test-group"; const agentId = "agent-xyz"; const { skillGroups: grps, skillGroupMembers: grpMembers } = await import("../services/skill-registry-schema.js"); await db.insert(grps).values({ id: groupId, name: "Test Group", description: null, isBuiltin: 0, createdAt: now, updatedAt: now, }); await db.insert(grpMembers).values({ groupId, skillId, addedAt: now }); const grpSvc = skillGroupService(); // Assign group (installs skills) await grpSvc.assignGroup(groupId, agentId, tmpAgentSkillsDir); expect(existsSync(path.join(tmpAgentSkillsDir, "code-review"))).toBe(true); // Remove group — should remove files from provided dir await grpSvc.removeGroup(groupId, agentId, tmpAgentSkillsDir); expect(existsSync(path.join(tmpAgentSkillsDir, "code-review"))).toBe(false); }); });