mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-09 20:51:22 +00:00
edcdc19ac5
Add CI check to ensure commits touching skills/<name>/** use Conventional Commit subjects. Also validates SKILL.md version alignment during publish/sync.
162 lines
5.4 KiB
JavaScript
162 lines
5.4 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const PACKAGE_DEPENDENCY_SECTIONS = [
|
|
"dependencies",
|
|
"optionalDependencies",
|
|
"peerDependencies",
|
|
"devDependencies",
|
|
];
|
|
|
|
const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules", "out", "dist", "build"]);
|
|
const SKIPPED_FILES = new Set([".DS_Store", "bun.lockb"]);
|
|
const MIME_MAP = {
|
|
".md": "text/markdown",
|
|
".ts": "text/plain",
|
|
".js": "text/javascript",
|
|
".mjs": "text/javascript",
|
|
".json": "application/json",
|
|
".yml": "text/yaml",
|
|
".yaml": "text/yaml",
|
|
".txt": "text/plain",
|
|
".html": "text/html",
|
|
".css": "text/css",
|
|
".xml": "text/xml",
|
|
".svg": "image/svg+xml",
|
|
};
|
|
|
|
export async function readSkillMetadataVersion(root) {
|
|
const skillFile = await findSkillMarkdown(root);
|
|
const source = await fs.readFile(skillFile, "utf8");
|
|
const version = readSkillFrontmatterVersion(source);
|
|
if (!version) {
|
|
throw new Error(`Missing version in ${path.relative(process.cwd(), skillFile) || skillFile}`);
|
|
}
|
|
return version;
|
|
}
|
|
|
|
export async function validateSkillMetadataVersion(root, expectedVersion) {
|
|
const actualVersion = await readSkillMetadataVersion(root);
|
|
if (actualVersion !== expectedVersion) {
|
|
throw new Error(
|
|
`SKILL.md version mismatch for ${path.basename(path.resolve(root))}: expected ${expectedVersion}, found ${actualVersion}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function readSkillFrontmatterVersion(source) {
|
|
const match = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
|
|
if (!match) return null;
|
|
|
|
for (const line of match[1].split(/\r?\n/)) {
|
|
const versionMatch = /^version:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/.exec(line.trim());
|
|
if (versionMatch) return versionMatch[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export async function listReleaseFiles(root) {
|
|
const resolvedRoot = path.resolve(root);
|
|
const files = [];
|
|
|
|
async function walk(folder) {
|
|
const entries = await fs.readdir(folder, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) continue;
|
|
if (entry.isFile() && SKIPPED_FILES.has(entry.name)) continue;
|
|
|
|
const fullPath = path.join(folder, entry.name);
|
|
if (entry.isDirectory()) {
|
|
await walk(fullPath);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
|
|
const relPath = path.relative(resolvedRoot, fullPath).split(path.sep).join("/");
|
|
const bytes = await fs.readFile(fullPath);
|
|
files.push({ relPath, bytes });
|
|
}
|
|
}
|
|
|
|
await walk(resolvedRoot);
|
|
files.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
|
return files;
|
|
}
|
|
|
|
async function findSkillMarkdown(root) {
|
|
const resolvedRoot = path.resolve(root);
|
|
for (const name of ["SKILL.md", "skill.md"]) {
|
|
const candidate = path.join(resolvedRoot, name);
|
|
try {
|
|
const stat = await fs.stat(candidate);
|
|
if (stat.isFile()) return candidate;
|
|
} catch {
|
|
// Try the next supported skill filename.
|
|
}
|
|
}
|
|
throw new Error(`Missing SKILL.md in ${resolvedRoot}`);
|
|
}
|
|
|
|
export async function validateSelfContainedRelease(root) {
|
|
const resolvedRoot = path.resolve(root);
|
|
const files = await listReleaseFiles(root);
|
|
for (const file of files.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
|
|
const packageDir = path.resolve(resolvedRoot, fromPosixRel(path.posix.dirname(file.relPath)));
|
|
const packageJson = JSON.parse(file.bytes.toString("utf8"));
|
|
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
|
const dependencies = packageJson[section];
|
|
if (!dependencies || typeof dependencies !== "object") continue;
|
|
|
|
for (const [name, spec] of Object.entries(dependencies)) {
|
|
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
|
|
const targetDir = path.resolve(packageDir, spec.slice(5));
|
|
if (!isWithinRoot(resolvedRoot, targetDir)) {
|
|
throw new Error(
|
|
`Release target is not self-contained: ${file.relPath} depends on ${name} via ${spec}`,
|
|
);
|
|
}
|
|
await fs.access(targetDir).catch(() => {
|
|
throw new Error(`Missing local dependency for release: ${file.relPath} -> ${spec}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const target of getPackageBinTargets(packageJson)) {
|
|
const targetPath = path.resolve(packageDir, target);
|
|
if (!isWithinRoot(resolvedRoot, targetPath)) {
|
|
throw new Error(`Release target is not self-contained: ${file.relPath} bin points to ${target}`);
|
|
}
|
|
await fs.access(targetPath).catch(() => {
|
|
throw new Error(`Missing package bin target for release: ${file.relPath} -> ${target}`);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
export function mimeType(relPath) {
|
|
const ext = path.extname(relPath).toLowerCase();
|
|
return MIME_MAP[ext] || "text/plain";
|
|
}
|
|
|
|
function getPackageBinTargets(packageJson) {
|
|
const bin = packageJson.bin;
|
|
if (typeof bin === "string" && bin.trim()) {
|
|
return [bin.trim()];
|
|
}
|
|
if (!bin || typeof bin !== "object" || Array.isArray(bin)) {
|
|
return [];
|
|
}
|
|
return Object.values(bin).filter((value) => typeof value === "string" && value.trim());
|
|
}
|
|
|
|
function fromPosixRel(relPath) {
|
|
return relPath === "." ? "." : relPath.split("/").join(path.sep);
|
|
}
|
|
|
|
function isWithinRoot(root, target) {
|
|
const resolvedRoot = path.resolve(root);
|
|
const relative = path.relative(resolvedRoot, path.resolve(target));
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|