Files
paperclip/server/src/__tests__/skills-catalog-service.test.ts
T
Devin Foley 5e086cb828 fix(server): resolve published skills catalog package root and fallback (#8327)
## Thinking Path

> - Paperclip is the open source control plane people use to run and
supervise AI-agent companies.
> - The skills system is part of that core operator experience because
agents and humans both depend on the catalog-backed Skills Manager
surfaces.
> - In source checkouts, the server can find the catalog manifest and
bundled skill files through monorepo-relative paths, but published
installs do not preserve that layout.
> - That mismatch makes `GET /api/skills/catalog` fail in npm/pnpm
installs even though the catalog package itself is present.
> - The server therefore needs to resolve the catalog from the published
`@paperclipai/skills-catalog` package first, while still keeping a
monorepo fallback for local development.
> - This pull request makes the published package the primary resolution
path, uses the same resolved package root for bundled skill file reads,
and degrades the list route safely when the manifest is unavailable.
> - The benefit is that Skills Manager catalog reads behave correctly in
packaged installs instead of only in repo-local development layouts.

## Linked Issues or Issue Description

Fixes #8316
Refs #7281
Refs #7313
Refs #7350
Refs #7860
Refs #8223
Refs #8227

## What Changed

- Added `@paperclipai/skills-catalog` as a runtime dependency of
`@paperclipai/server`.
- Exported `./package.json` from `@paperclipai/skills-catalog` so the
server can resolve the published package root directly.
- Updated `server/src/services/skills-catalog.ts` to resolve the
manifest and package root from the published package first, with the
monorepo path retained only as a development fallback.
- Applied that resolved package root to bundled catalog file reads so
manifest lookup and skill-file reads use the same published layout.
- Added `listCatalogSkillsOrEmpty()` so `GET /api/skills/catalog`
returns `[]` and logs a warning when the manifest is unavailable instead
of surfacing a 500.
- Added targeted server tests for published-package resolution and
missing-manifest fallback handling.

## Verification

- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/skills-catalog-service.test.ts
src/__tests__/company-skills-routes.test.ts`
- `pnpm --filter @paperclipai/server build`
- Packaging smoke:
- pack `@paperclipai/shared` and `@paperclipai/skills-catalog` from this
checkout
  - mount those packed artifacts under `server/dist/node_modules`
  - import `server/dist/services/skills-catalog.js`
- verify a bundled catalog `SKILL.md` resolves and reads successfully
from the packed package layout

## Risks

- Low risk: the change is narrowly scoped to catalog package resolution
and fallback behavior.
- The new `./package.json` export slightly broadens the catalog
package's public surface, so reviewers should confirm that is an
acceptable runtime contract.
- The empty-array fallback intentionally changes failure mode for a
missing manifest from `500` to a warning + empty payload, which is safer
for packaged installs but could hide packaging regressions if logs are
not monitored.

## Model Used

- OpenAI Codex via Paperclip `codex_local` (GPT-5-based coding agent;
exact backend model ID/context window not exposed in this harness), with
tool use, shell execution, git, and local test/build verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-06-19 09:14:41 -07:00

236 lines
8.7 KiB
TypeScript

import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CatalogSkill } from "@paperclipai/shared";
const mockExistsSync = vi.hoisted(() => vi.fn());
const mockReadFileSync = vi.hoisted(() => vi.fn());
const mockStatSync = vi.hoisted(() => vi.fn());
const mockReadFile = vi.hoisted(() => vi.fn());
const mockRequireResolve = vi.hoisted(() => vi.fn());
const mockLoggerWarn = vi.hoisted(() => vi.fn());
vi.doMock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
statSync: mockStatSync,
promises: {
...actual.promises,
readFile: mockReadFile,
},
};
});
vi.doMock("node:module", async () => {
const actual = await vi.importActual<typeof import("node:module")>("node:module");
return {
...actual,
createRequire: () => ({
resolve: mockRequireResolve,
}),
};
});
vi.doMock("../middleware/logger.js", () => ({
logger: {
warn: mockLoggerWarn,
},
}));
function catalogSkill(slug: string, name = slug): CatalogSkill {
return {
id: `paperclipai:bundled:software-development:${slug}`,
key: `paperclipai/bundled/software-development/${slug}`,
kind: "bundled",
category: "software-development",
slug,
name,
description: `${name} catalog skill used by the reload test.`,
path: `catalog/bundled/software-development/${slug}`,
entrypoint: "SKILL.md",
trustLevel: "markdown_only",
compatibility: "compatible",
defaultInstall: false,
recommendedForRoles: ["engineer"],
requires: [],
tags: ["test"],
files: [{ path: "SKILL.md", kind: "skill", sizeBytes: 8, sha256: `sha256:${slug}` }],
contentHash: `sha256:${slug}`,
};
}
function sha256(value: string | Buffer) {
return createHash("sha256").update(value).digest("hex");
}
function manifest(skills: CatalogSkill[], packageVersion = "0.3.1") {
return JSON.stringify({
schemaVersion: 1,
packageName: "@paperclipai/skills-catalog",
packageVersion,
generatedAt: "2026-05-28T00:00:00.000Z",
skills,
});
}
describe("skills catalog service", () => {
let manifestJson: string;
let manifestMtimeMs: number;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.unstubAllGlobals();
manifestJson = manifest([catalogSkill("old-skill", "Old Skill")]);
manifestMtimeMs = 1;
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation(() => manifestJson);
mockStatSync.mockImplementation(() => ({
mtimeMs: manifestMtimeMs,
size: Buffer.byteLength(manifestJson),
}));
mockReadFile.mockImplementation(async (filePath: string) => `content:${filePath}`);
mockRequireResolve.mockImplementation((specifier: string) => {
if (specifier === "@paperclipai/skills-catalog/package.json") {
return "/published/node_modules/@paperclipai/skills-catalog/package.json";
}
if (specifier === "@paperclipai/skills-catalog/catalog.json") {
return "/published/node_modules/@paperclipai/skills-catalog/generated/catalog.json";
}
throw new Error(`Unexpected specifier: ${specifier}`);
});
});
it("caches and reloads the generated catalog manifest when it changes", async () => {
const service = await import("../services/skills-catalog.js");
expect(service.listCatalogSkills().map((skill) => skill.key)).toEqual([
"paperclipai/bundled/software-development/old-skill",
]);
expect(service.listCatalogSkills().map((skill) => skill.key)).toEqual([
"paperclipai/bundled/software-development/old-skill",
]);
expect(mockReadFileSync).toHaveBeenCalledTimes(1);
manifestJson = manifest([catalogSkill("new-skill", "New Skill")], "0.3.2");
manifestMtimeMs += 1;
expect(service.listCatalogSkills().map((skill) => skill.key)).toEqual([
"paperclipai/bundled/software-development/new-skill",
]);
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
expect(() => service.getCatalogSkillOrThrow("old-skill")).toThrow("Catalog skill not found");
expect(service.getCatalogPackageMetadata()).toEqual({
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.2",
});
});
it("rejects catalog asset previews without decoding bytes as utf8", async () => {
const imageSkill = catalogSkill("with-image", "With Image");
imageSkill.files = [
...imageSkill.files,
{ path: "assets/logo.png", kind: "asset", sizeBytes: 4, sha256: "sha256:logo" },
];
manifestJson = manifest([imageSkill]);
const service = await import("../services/skills-catalog.js");
await expect(service.readCatalogSkillFile(imageSkill.id, "assets/logo.png")).rejects.toMatchObject({
status: 415,
message: "Catalog asset previews are not supported.",
});
expect(mockReadFile).not.toHaveBeenCalled();
});
it("reads referenced GitHub catalog files from pinned raw bytes", async () => {
const markdown = "---\nname: remote\n---\n\n# Remote\n";
const remoteSkill = catalogSkill("remote-skill", "Remote Skill");
remoteSkill.source = {
type: "github",
hostname: "github.com",
owner: "example",
repo: "remote-skill",
ref: "v1.0.0",
commit: "0123456789abcdef0123456789abcdef01234567",
path: "skills/remote",
url: "https://github.com/example/remote-skill/tree/v1.0.0/skills/remote",
};
remoteSkill.files = [
{ path: "SKILL.md", kind: "skill", sizeBytes: Buffer.byteLength(markdown), sha256: sha256(markdown) },
];
manifestJson = manifest([remoteSkill]);
const fetchMock = vi.fn(async (url: string) => {
expect(url).toBe("https://raw.githubusercontent.com/example/remote-skill/0123456789abcdef0123456789abcdef01234567/skills/remote/SKILL.md");
return new Response(markdown, { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
const service = await import("../services/skills-catalog.js");
await expect(service.readCatalogSkillFile(remoteSkill.id, "SKILL.md")).resolves.toMatchObject({
catalogSkillId: remoteSkill.id,
path: "SKILL.md",
content: markdown,
markdown: true,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(mockReadFile).not.toHaveBeenCalled();
});
it("resolves the manifest and bundled skill files from the published package layout", async () => {
const publishedMarkdown = "---\nname: published\n---\n\n# Published\n";
const publishedSkill = catalogSkill("published-skill", "Published Skill");
publishedSkill.files = [
{
path: "SKILL.md",
kind: "skill",
sizeBytes: Buffer.byteLength(publishedMarkdown),
sha256: sha256(publishedMarkdown),
},
];
manifestJson = manifest([publishedSkill], "0.3.2");
mockReadFile.mockImplementationOnce(async () => Buffer.from(publishedMarkdown));
const service = await import("../services/skills-catalog.js");
expect(service.getCatalogPackageMetadata()).toEqual({
packageName: "@paperclipai/skills-catalog",
packageVersion: "0.3.2",
});
await expect(service.readCatalogSkillFile(publishedSkill.id, "SKILL.md")).resolves.toMatchObject({
catalogSkillId: publishedSkill.id,
path: "SKILL.md",
content: publishedMarkdown,
markdown: true,
});
expect(mockReadFileSync).toHaveBeenCalledWith(
"/published/node_modules/@paperclipai/skills-catalog/generated/catalog.json",
"utf8",
);
expect(mockReadFile).toHaveBeenCalledWith(
"/published/node_modules/@paperclipai/skills-catalog/catalog/bundled/software-development/published-skill/SKILL.md",
);
expect(mockRequireResolve).toHaveBeenCalledWith("@paperclipai/skills-catalog/package.json");
expect(mockRequireResolve).toHaveBeenCalledWith("@paperclipai/skills-catalog/catalog.json");
});
it("returns an empty list, caches package-resolution failure, and logs only once when the manifest cannot be resolved", async () => {
mockRequireResolve.mockImplementation(() => {
const error = new Error("not found") as Error & { code?: string };
error.code = "ERR_MODULE_NOT_FOUND";
throw error;
});
mockExistsSync.mockReturnValue(false);
const service = await import("../services/skills-catalog.js");
expect(service.listCatalogSkillsOrEmpty()).toEqual([]);
expect(service.listCatalogSkillsOrEmpty()).toEqual([]);
expect(mockRequireResolve).toHaveBeenCalledTimes(1);
expect(mockLoggerWarn).toHaveBeenCalledWith(
{ err: expect.any(service.CatalogManifestUnavailableError) },
"skills catalog manifest unavailable; returning empty catalog",
);
expect(mockLoggerWarn).toHaveBeenCalledTimes(1);
});
});