Add referenced last30days catalog entry

This commit is contained in:
Dotta
2026-06-04 20:01:25 +00:00
parent 1227bb8ead
commit bee2b25f5d
17 changed files with 1276 additions and 49 deletions
+53 -10
View File
@@ -44,7 +44,6 @@ import {
copyCatalogSkillFile,
getCatalogPackageMetadata,
getCatalogSkillOrThrow,
readCatalogSkillFile,
resolveCatalogSkillReference,
} from "./skills-catalog.js";
import {
@@ -2452,7 +2451,7 @@ export function companySkillService(db: Db) {
buildSkillRuntimeName(catalogSkill.key, skill.slug),
);
await copySkillDirectory(originSnapshotLocator, materializedDir);
const markdown = (await readCatalogSkillFile(catalogSkill.id, catalogSkill.entrypoint)).content;
const markdown = await fs.readFile(path.join(originSnapshotLocator, catalogSkill.entrypoint), "utf8");
const nextMetadata = buildCatalogSkillMetadata(catalogSkill, skill, originSnapshotLocator);
const nextValues = {
name: catalogSkill.name,
@@ -2942,6 +2941,7 @@ export function companySkillService(db: Db) {
catalogKind: catalogSkill.kind,
catalogCategory: catalogSkill.category,
catalogPath: catalogSkill.path,
catalogSource: catalogSkill.source ?? null,
packageName: packageMetadata.packageName,
packageVersion: packageMetadata.packageVersion,
originHash: catalogSkill.contentHash,
@@ -2956,11 +2956,36 @@ export function companySkillService(db: Db) {
if (catalogSkill.compatibility !== "compatible") {
throw unprocessable(`Catalog skill ${catalogSkill.id} is not compatible.`);
}
if (catalogSkill.trustLevel === "scripts_executables") {
throw unprocessable(
"Catalog skill contains executable scripts and cannot be force-installed until security review semantics allow it.",
);
}
}
async function auditCatalogSkillSnapshot(
companyId: string,
catalogSkill: CatalogSkill,
slug: string,
sourceDir: string,
) {
return auditInstalledSkillBytes({
id: randomUUID(),
companyId,
key: catalogSkill.key,
slug,
name: catalogSkill.name,
description: catalogSkill.description,
markdown: "",
sourceType: "catalog",
sourceLocator: sourceDir,
sourceRef: catalogSkill.contentHash,
trustLevel: catalogSkill.trustLevel,
compatibility: catalogSkill.compatibility,
fileInventory: catalogSkill.files.map((entry) => ({ path: entry.path, kind: entry.kind })),
metadata: {
sourceKind: "catalog",
catalogId: catalogSkill.id,
originHash: catalogSkill.contentHash,
},
createdAt: new Date(),
updatedAt: new Date(),
});
}
async function installFromCatalog(
@@ -3021,9 +3046,27 @@ export function companySkillService(db: Db) {
}
}
const materializedDir = await materializeCatalogManifestSkillFiles(companyId, catalogSkill, slug);
const originSnapshotLocator = await materializeCatalogOriginSnapshot(companyId, catalogSkill, slug);
const markdown = (await readCatalogSkillFile(catalogSkill.id, catalogSkill.entrypoint)).content;
let materializedDir: string | null = null;
let originSnapshotLocator: string | null = null;
try {
originSnapshotLocator = await materializeCatalogOriginSnapshot(companyId, catalogSkill, slug);
const candidateAudit = await auditCatalogSkillSnapshot(companyId, catalogSkill, slug, originSnapshotLocator);
if (candidateAudit.verdict === "fail") {
throw unprocessable("Catalog install is blocked by hard-stop audit findings.", {
updateHoldReason: "audit_hard_stop",
audit: candidateAudit,
});
}
materializedDir = await materializeCatalogManifestSkillFiles(companyId, catalogSkill, slug);
} catch (error) {
if (materializedDir) await fs.rm(materializedDir, { recursive: true, force: true }).catch(() => undefined);
if (originSnapshotLocator) await fs.rm(originSnapshotLocator, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
if (!materializedDir || !originSnapshotLocator) {
throw unprocessable("Catalog install did not materialize pinned files.");
}
const markdown = await fs.readFile(path.join(originSnapshotLocator, catalogSkill.entrypoint), "utf8");
const metadata = buildCatalogSkillMetadata(catalogSkill, existingByKey, originSnapshotLocator);
const values = {
companyId,
+60 -17
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, statSync } from "node:fs";
import { promises as fs } from "node:fs";
import path from "node:path";
@@ -6,8 +7,10 @@ import type {
CatalogSkill,
CatalogSkillFileDetail,
CatalogSkillListQuery,
CatalogSkillSource,
} from "@paperclipai/shared";
import { HttpError, conflict, notFound } from "../errors.js";
import { HttpError, conflict, notFound, unprocessable } from "../errors.js";
import { ghFetch, resolveRawGitHubUrl } from "./github-fetch.js";
import { normalizePortablePath } from "./portable-path.js";
interface CatalogManifestFile {
@@ -93,6 +96,60 @@ function resolveCatalogPackageRoot() {
return catalogPackageRoot;
}
function sourceRootPath(source: CatalogSkillSource) {
return source.path ? normalizePortablePath(source.path) : "";
}
function resolveCatalogSourcePath(source: CatalogSkillSource, relativePath: string) {
const sourceRoot = sourceRootPath(source);
return sourceRoot ? `${sourceRoot}/${relativePath}` : relativePath;
}
async function fetchCatalogSourceFile(
skill: CatalogSkill,
relativePath: string,
): Promise<Buffer> {
const source = skill.source;
if (!source) {
const packageRoot = resolveCatalogPackageRoot();
const absolutePath = path.resolve(packageRoot, skill.path, relativePath);
const skillRoot = path.resolve(packageRoot, skill.path);
if (absolutePath !== skillRoot && !absolutePath.startsWith(`${skillRoot}${path.sep}`)) {
throw notFound("Catalog skill file not found");
}
return fs.readFile(absolutePath);
}
if (source.type !== "github") {
throw unprocessable(`Unsupported catalog source type: ${(source as { type: string }).type}`);
}
const sourcePath = resolveCatalogSourcePath(source, relativePath);
const url = resolveRawGitHubUrl(source.hostname, source.owner, source.repo, source.commit, sourcePath);
const response = await ghFetch(url);
if (!response.ok) {
throw unprocessable(`Failed to fetch pinned catalog file ${sourcePath} from ${source.owner}/${source.repo}@${source.commit}: HTTP ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
async function readCatalogFileBytes(
skill: CatalogSkill,
relativePath: string,
): Promise<Buffer> {
const fileEntry = skill.files.find((entry) => entry.path === relativePath);
if (!fileEntry) {
throw notFound("Catalog skill file not found");
}
const bytes = await fetchCatalogSourceFile(skill, relativePath);
const actualSha = createHash("sha256").update(bytes).digest("hex");
if (actualSha !== fileEntry.sha256) {
throw unprocessable(`Pinned catalog file hash mismatch for ${skill.id}:${relativePath}.`);
}
return bytes;
}
function searchText(skill: CatalogSkill) {
return [
skill.id,
@@ -152,18 +209,11 @@ export async function readCatalogSkillFile(
throw notFound("Catalog skill file not found");
}
const packageRoot = resolveCatalogPackageRoot();
const absolutePath = path.resolve(packageRoot, skill.path, normalizedPath);
const skillRoot = path.resolve(packageRoot, skill.path);
if (absolutePath !== skillRoot && !absolutePath.startsWith(`${skillRoot}${path.sep}`)) {
throw notFound("Catalog skill file not found");
}
if (fileEntry.kind === "asset") {
throw new HttpError(415, "Catalog asset previews are not supported.");
}
const content = await fs.readFile(absolutePath, "utf8");
const content = (await readCatalogFileBytes(skill, normalizedPath)).toString("utf8");
return {
catalogSkillId: skill.id,
path: normalizedPath,
@@ -182,14 +232,7 @@ export async function copyCatalogSkillFile(reference: string, relativePath: stri
throw notFound("Catalog skill file not found");
}
const packageRoot = resolveCatalogPackageRoot();
const absolutePath = path.resolve(packageRoot, skill.path, normalizedPath);
const skillRoot = path.resolve(packageRoot, skill.path);
if (absolutePath !== skillRoot && !absolutePath.startsWith(`${skillRoot}${path.sep}`)) {
throw notFound("Catalog skill file not found");
}
await fs.copyFile(absolutePath, targetPath);
await fs.writeFile(targetPath, await readCatalogFileBytes(skill, normalizedPath));
}
export function getCatalogPackageMetadata() {