forked from EduCraft/curriculum-project-hub
218 lines
8.1 KiB
TypeScript
218 lines
8.1 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import {
|
|
cp,
|
|
lstat,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join, relative, resolve } from "node:path";
|
|
import type { RoleSkillEntry } from "./models.js";
|
|
|
|
const MAX_SKILL_FILES = 512;
|
|
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
|
|
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
|
|
const RUNTIME_PLUGIN_NAME = "cph-runtime";
|
|
|
|
export interface ImportedSkillContent {
|
|
readonly name: string;
|
|
readonly description: string | undefined;
|
|
readonly contentDigest: string;
|
|
}
|
|
|
|
export interface RunSkillPlugin {
|
|
readonly root: string;
|
|
readonly skillIds: readonly string[];
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
export async function importSkillDirectory(input: {
|
|
readonly sourceDir: string;
|
|
readonly storeRoot: string;
|
|
}): Promise<ImportedSkillContent> {
|
|
const source = await inspectSkillDirectory(input.sourceDir);
|
|
const versionsRoot = join(input.storeRoot, "versions");
|
|
await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
|
|
const destination = join(versionsRoot, source.contentDigest);
|
|
|
|
try {
|
|
const existing = await inspectSkillDirectory(destination);
|
|
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
|
|
throw new Error(`stored skill content digest mismatch: ${source.name}`);
|
|
}
|
|
return source;
|
|
} catch (error) {
|
|
if (!isMissingPath(error)) throw error;
|
|
}
|
|
|
|
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
|
|
try {
|
|
await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true });
|
|
const copied = await inspectSkillDirectory(temporary);
|
|
if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) {
|
|
throw new Error(`skill changed while importing: ${source.name}`);
|
|
}
|
|
await rename(temporary, destination);
|
|
} catch (error) {
|
|
await rm(temporary, { recursive: true, force: true });
|
|
if (isDestinationExists(error)) {
|
|
const existing = await inspectSkillDirectory(destination);
|
|
if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source;
|
|
}
|
|
throw error;
|
|
}
|
|
return source;
|
|
}
|
|
|
|
export async function prepareRunSkillPlugin(input: {
|
|
readonly storeRoot: string;
|
|
readonly runId: string;
|
|
readonly skills: readonly RoleSkillEntry[];
|
|
}): Promise<RunSkillPlugin | null> {
|
|
if (input.skills.length === 0) return null;
|
|
const names = new Set<string>();
|
|
for (const skill of input.skills) {
|
|
requireSkillName(skill.name);
|
|
if (!DIGEST_PATTERN.test(skill.contentDigest)) {
|
|
throw new Error(`skill ${skill.name} has invalid content digest`);
|
|
}
|
|
if (names.has(skill.name)) throw new Error(`duplicate role skill: ${skill.name}`);
|
|
names.add(skill.name);
|
|
}
|
|
|
|
const runtimeRoot = join(input.storeRoot, "runtime");
|
|
await mkdir(runtimeRoot, { recursive: true, mode: 0o750 });
|
|
const pluginRoot = join(runtimeRoot, `run-${randomUUID()}`);
|
|
try {
|
|
await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true, mode: 0o750 });
|
|
await mkdir(join(pluginRoot, "skills"), { recursive: true, mode: 0o750 });
|
|
await writeFile(
|
|
join(pluginRoot, ".claude-plugin", "plugin.json"),
|
|
`${JSON.stringify({
|
|
name: RUNTIME_PLUGIN_NAME,
|
|
description: `Runtime skill snapshot for ${input.runId}`,
|
|
version: "1",
|
|
}, null, 2)}\n`,
|
|
{ mode: 0o640 },
|
|
);
|
|
|
|
for (const skill of input.skills) {
|
|
const sourceDir = join(input.storeRoot, "versions", skill.contentDigest);
|
|
const stored = await inspectSkillDirectory(sourceDir);
|
|
if (stored.contentDigest !== skill.contentDigest) {
|
|
throw new Error(`skill ${skill.name} content digest mismatch`);
|
|
}
|
|
if (stored.name !== skill.name) {
|
|
throw new Error(`skill name mismatch: expected ${skill.name}, got ${stored.name}`);
|
|
}
|
|
await cp(sourceDir, join(pluginRoot, "skills", skill.name), {
|
|
recursive: true,
|
|
force: false,
|
|
errorOnExist: true,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
await rm(pluginRoot, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
root: pluginRoot,
|
|
skillIds: input.skills.map((skill) => `${RUNTIME_PLUGIN_NAME}:${skill.name}`),
|
|
async cleanup() {
|
|
await rm(pluginRoot, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
export function readSkillStoreRoot(env: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
const configured = env["HUB_SKILL_STORE_ROOT"]?.trim();
|
|
if (configured !== undefined && configured !== "") return resolve(configured);
|
|
const stateRoot = env["XDG_STATE_HOME"]?.trim();
|
|
if (stateRoot === undefined || stateRoot === "") {
|
|
throw new Error("HUB_SKILL_STORE_ROOT or XDG_STATE_HOME is required");
|
|
}
|
|
return resolve(stateRoot, "skills");
|
|
}
|
|
|
|
export async function verifyStoredSkill(input: {
|
|
readonly storeRoot: string;
|
|
readonly name: string;
|
|
readonly contentDigest: string;
|
|
}): Promise<void> {
|
|
requireSkillName(input.name);
|
|
if (!DIGEST_PATTERN.test(input.contentDigest)) {
|
|
throw new Error(`skill ${input.name} has invalid content digest`);
|
|
}
|
|
const stored = await inspectSkillDirectory(join(input.storeRoot, "versions", input.contentDigest));
|
|
if (stored.name !== input.name || stored.contentDigest !== input.contentDigest) {
|
|
throw new Error(`stored skill verification failed: ${input.name}`);
|
|
}
|
|
}
|
|
|
|
async function inspectSkillDirectory(directory: string): Promise<ImportedSkillContent> {
|
|
const root = resolve(directory);
|
|
const rootStat = await lstat(root);
|
|
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
throw new Error(`skill root must be a real directory: ${root}`);
|
|
}
|
|
const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
|
await walk(root, root, files);
|
|
if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`);
|
|
const totalBytes = files.reduce((sum, file) => sum + file.bytes.byteLength, 0);
|
|
if (totalBytes > MAX_SKILL_BYTES) throw new Error(`skill is too large: ${totalBytes} bytes`);
|
|
const manifest = files.find((file) => file.path === "SKILL.md");
|
|
if (manifest === undefined) throw new Error(`skill manifest missing: ${join(root, "SKILL.md")}`);
|
|
const frontmatter = manifest.bytes.toString("utf8");
|
|
const name = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim();
|
|
if (name === undefined) throw new Error("skill manifest name missing");
|
|
requireSkillName(name);
|
|
const description = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim();
|
|
|
|
const hash = createHash("sha256");
|
|
for (const file of files.sort((left, right) => left.path.localeCompare(right.path))) {
|
|
hash.update(`${Buffer.byteLength(file.path)}:`);
|
|
hash.update(file.path);
|
|
hash.update(`${file.bytes.byteLength}:`);
|
|
hash.update(file.bytes);
|
|
}
|
|
return { name, description, contentDigest: hash.digest("hex") };
|
|
}
|
|
|
|
async function walk(
|
|
root: string,
|
|
directory: string,
|
|
files: Array<{ readonly path: string; readonly bytes: Buffer }>,
|
|
): Promise<void> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const fullPath = join(directory, entry.name);
|
|
if (entry.isSymbolicLink()) throw new Error(`skill symlink is forbidden: ${fullPath}`);
|
|
if (entry.isDirectory()) {
|
|
await walk(root, fullPath, files);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) throw new Error(`skill contains unsupported filesystem entry: ${fullPath}`);
|
|
const relativePath = relative(root, fullPath);
|
|
files.push({ path: relativePath, bytes: await readFile(fullPath) });
|
|
if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`);
|
|
}
|
|
}
|
|
|
|
function requireSkillName(name: string): void {
|
|
if (!SKILL_NAME_PATTERN.test(name)) throw new Error(`invalid skill name: ${name}`);
|
|
}
|
|
|
|
function isMissingPath(error: unknown): boolean {
|
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
}
|
|
|
|
function isDestinationExists(error: unknown): boolean {
|
|
return typeof error === "object" && error !== null && "code" in error &&
|
|
(error.code === "EEXIST" || error.code === "ENOTEMPTY");
|
|
}
|