748 lines
28 KiB
JavaScript
748 lines
28 KiB
JavaScript
import { promises as fsp } from "node:fs";
|
||
import { spawnSync } from "node:child_process";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import crypto from "node:crypto";
|
||
import { findDshHome, atomicWriteFile } from "./core.js";
|
||
import { extractZipArchive, listZipFile } from "./zip-extract.js";
|
||
|
||
export { findDshHome };
|
||
|
||
/** 与官方 @deepseek-ai/dsh-skill 一致:kebab-case。 */
|
||
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||
const MAX_SKILL_BYTES = 256 * 1024;
|
||
const MAX_ARCHIVE_BYTES = 12 * 1024 * 1024;
|
||
const MAX_IMPORT_FILES = 200;
|
||
const MAX_DESCRIPTION = 500;
|
||
const SKIP_COPY_NAMES = new Set([".", "..", ".system", ".git", "node_modules", "__MACOSX", ".DS_Store"]);
|
||
const MAX_UNPACKED_BYTES = 50 * 1024 * 1024;
|
||
|
||
export function validSkillName(id) {
|
||
return typeof id === "string" && SKILL_NAME_RE.test(id);
|
||
}
|
||
|
||
export function skillsDir(dshHome) {
|
||
return path.join(dshHome, "skills");
|
||
}
|
||
|
||
export function skillBackupDir(dshHome) {
|
||
return path.join(dshHome, "dsh-purge-backups", "skills");
|
||
}
|
||
|
||
export function skillBundleDir(dshHome, id) {
|
||
return path.join(skillsDir(dshHome), id);
|
||
}
|
||
|
||
export function skillBundleFile(dshHome, id) {
|
||
return path.join(skillBundleDir(dshHome, id), "SKILL.md");
|
||
}
|
||
|
||
export function skillFlatFile(dshHome, id) {
|
||
return path.join(skillsDir(dshHome), `${id}.md`);
|
||
}
|
||
|
||
/** 对外只报 $DSH_HOME/...,不暴露本机盘符或安装目录。 */
|
||
export function displayUnderHome(dshHome, absPath) {
|
||
if (!absPath) return "";
|
||
const home = path.resolve(dshHome);
|
||
const abs = path.resolve(absPath);
|
||
if (abs === home) return "$DSH_HOME";
|
||
const rel = path.relative(home, abs);
|
||
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return "$DSH_HOME";
|
||
return `$DSH_HOME/${rel.split(/[/\\]/).join("/")}`;
|
||
}
|
||
|
||
export function toPublicSkill(dshHome, item) {
|
||
if (!item || typeof item !== "object") return item;
|
||
const next = { ...item };
|
||
if (typeof next.path === "string") next.path = displayUnderHome(dshHome, next.path);
|
||
if (typeof next.backup === "string") next.backup = displayUnderHome(dshHome, next.backup);
|
||
return next;
|
||
}
|
||
|
||
function assertInside(root, target) {
|
||
const a = path.resolve(root);
|
||
const b = path.resolve(target);
|
||
const prefix = a.endsWith(path.sep) ? a : a + path.sep;
|
||
if (b !== a && !b.startsWith(prefix)) {
|
||
throw new Error("skill path escaped official skills directory");
|
||
}
|
||
}
|
||
|
||
/** 源与 $DSH_HOME/skills 重叠时先删后拷会把自己删空(#54)。 */
|
||
function pathsOverlap(a, b) {
|
||
// 必须 realpath — 只 resolve 不解 symlink,软链到 skills 下的路径 resolve 后不匹配,
|
||
// 删除时顺着 symlink 把 skills 删穿。realpath 对不存在路径会 throw,fallback 到 resolve。
|
||
const toReal = (p) => {
|
||
const abs = path.resolve(String(p || ""));
|
||
if (!abs) return "";
|
||
try { return fs.realpathSync.native ? fs.realpathSync.native(abs) : fs.realpathSync(abs); }
|
||
catch { return abs; }
|
||
};
|
||
const left = toReal(a);
|
||
const right = toReal(b);
|
||
if (!left || !right) return false;
|
||
if (left === right) return true;
|
||
const leftPrefix = left.endsWith(path.sep) ? left : left + path.sep;
|
||
const rightPrefix = right.endsWith(path.sep) ? right : right + path.sep;
|
||
return left.startsWith(rightPrefix) || right.startsWith(leftPrefix);
|
||
}
|
||
|
||
function assertImportSourceOutsideSkills(dshHome, srcPath) {
|
||
const root = skillsDir(dshHome);
|
||
if (!pathsOverlap(srcPath, root)) return;
|
||
throw new Error("不能从 $DSH_HOME/skills 自身(或其子目录)导入,请换一个源目录");
|
||
}
|
||
|
||
function yamlScalar(value) {
|
||
const text = String(value ?? "").trim();
|
||
if (!text) return '""';
|
||
if (/^[A-Za-z0-9._+/-]+$/.test(text)) return text;
|
||
return JSON.stringify(text);
|
||
}
|
||
|
||
export function splitFrontmatter(raw) {
|
||
const text = String(raw || "").replace(/^\uFEFF/, "");
|
||
if (!text.startsWith("---")) return null;
|
||
const afterOpen = text.slice(3);
|
||
if (!/^\r?\n/.test(afterOpen)) return null;
|
||
const rest = afterOpen.replace(/^\r?\n/, "");
|
||
const close = rest.match(/\r?\n---(?:\r?\n|$)/);
|
||
if (!close || close.index == null) return null;
|
||
return {
|
||
head: rest.slice(0, close.index),
|
||
body: rest.slice(close.index + close[0].length),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* YAML 块标量解析:覆盖 `|`, `>`, `|-`, `|+`, `>-`, `>+`, `|2` 等 chomp/indent 指示。
|
||
* 只扫 frontmatter,返回**压成单行**的字符串(换行→空格),因为上游面板按单行描述渲染。
|
||
* 不支持 anchor / alias / tag / 多行 flow —— SKILL.md 不该出现。
|
||
*/
|
||
function escapeYamlKey(s) {
|
||
// frontmatter key 走到 RegExp 构造前必须 escape:当前调用点都是硬编码字面量,
|
||
// 但万一未来有数据驱动场景,点 / 美元符 / 括号在正则里是元字符,会错配或 ReDoS。
|
||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function blockScalarValue(head, key) {
|
||
const lines = String(head || "").split(/\r?\n/);
|
||
const headerRe = new RegExp(`^(\\s*)${escapeYamlKey(key)}:\\s*([|>])([+-]?)(\\d*)\\s*(?:#.*)?$`);
|
||
let headerIdx = -1;
|
||
let keyIndent = 0;
|
||
let style = "|";
|
||
let chomp = "";
|
||
let indentHint = 0;
|
||
for (let i = 0; i < lines.length; i += 1) {
|
||
const m = lines[i].match(headerRe);
|
||
if (!m) continue;
|
||
headerIdx = i;
|
||
keyIndent = m[1].length;
|
||
style = m[2];
|
||
chomp = m[3] || "";
|
||
indentHint = m[4] ? parseInt(m[4], 10) : 0;
|
||
break;
|
||
}
|
||
if (headerIdx < 0) return "";
|
||
const body = [];
|
||
let blockIndent = indentHint > 0 ? keyIndent + indentHint : 0;
|
||
for (let i = headerIdx + 1; i < lines.length; i += 1) {
|
||
const line = lines[i];
|
||
if (line.trim() === "") { body.push(""); continue; }
|
||
const indent = line.match(/^(\s*)/)[1].length;
|
||
if (indent <= keyIndent) break;
|
||
if (!blockIndent) blockIndent = indent;
|
||
if (indent < blockIndent) break;
|
||
body.push(line.slice(blockIndent));
|
||
}
|
||
while (body.length && body[body.length - 1] === "") body.pop();
|
||
let text;
|
||
if (style === "|") {
|
||
text = body.join("\n");
|
||
} else {
|
||
text = "";
|
||
for (let i = 0; i < body.length; i += 1) {
|
||
const cur = body[i];
|
||
if (i === 0) { text += cur; continue; }
|
||
if (cur === "") { text += "\n"; continue; }
|
||
if (body[i - 1] === "") { text += cur; continue; }
|
||
text += " " + cur;
|
||
}
|
||
}
|
||
if (chomp === "+") text += "\n";
|
||
else if (chomp !== "-" && text.length > 0) text += "\n";
|
||
return text.replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function yamlLineField(head, key) {
|
||
const re = new RegExp(`^${escapeYamlKey(key)}:\\s*(.*)$`, "m");
|
||
const match = String(head || "").match(re);
|
||
if (!match) return "";
|
||
let value = match[1].trim();
|
||
if (!value) return "";
|
||
// 块标量:`|`, `>`, 加 chomp (`-`/`+`) 或 indent 指示(`|2`)—— 调用 blockScalarValue 解析正文。
|
||
if (/^[|>][+-]?\d*\s*(?:#.*)?$/.test(value)) return blockScalarValue(head, key);
|
||
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
||
try {
|
||
return value.startsWith("\"") ? JSON.parse(value) : value.slice(1, -1);
|
||
} catch {
|
||
return value.slice(1, -1);
|
||
}
|
||
}
|
||
const comment = value.indexOf(" #");
|
||
if (comment >= 0) value = value.slice(0, comment).trim();
|
||
return value;
|
||
}
|
||
|
||
function yamlBoolField(head, key) {
|
||
const re = new RegExp(`^${key}:\\s*(.*)$`, "m");
|
||
const match = String(head || "").match(re);
|
||
if (!match) return { present: false };
|
||
let value = match[1].trim();
|
||
if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
|
||
value = value.slice(1, -1).trim();
|
||
}
|
||
const comment = value.indexOf(" #");
|
||
if (comment >= 0) value = value.slice(0, comment).trim();
|
||
const norm = value.toLowerCase();
|
||
if (["true", "yes", "on", "1"].includes(norm)) return { present: true, value: true };
|
||
if (["false", "no", "off", "0"].includes(norm)) return { present: true, value: false };
|
||
return { present: true, invalid: true };
|
||
}
|
||
|
||
export function parseSkillDocument(raw) {
|
||
const parts = splitFrontmatter(raw);
|
||
if (!parts) return { ok: false, error: "缺少 YAML frontmatter(须以 --- 包围)" };
|
||
const name = yamlLineField(parts.head, "name");
|
||
const description = yamlLineField(parts.head, "description");
|
||
if (!validSkillName(name)) return { ok: false, error: "frontmatter.name 必须是 kebab-case,例如 code-review" };
|
||
if (!description) return { ok: false, error: "frontmatter.description 必填(单行)" };
|
||
const disableModel = yamlBoolField(parts.head, "disable-model-invocation");
|
||
const userInvocable = yamlBoolField(parts.head, "user-invocable");
|
||
if (disableModel.invalid) return { ok: false, error: "disable-model-invocation 必须是布尔值(true/false)" };
|
||
if (userInvocable.invalid) return { ok: false, error: "user-invocable 必须是布尔值(true/false)" };
|
||
return {
|
||
ok: true,
|
||
name,
|
||
description,
|
||
whenToUse: yamlLineField(parts.head, "whenToUse"),
|
||
modelInvocable: disableModel.value !== true,
|
||
userInvocable: userInvocable.value !== false,
|
||
body: parts.body,
|
||
head: parts.head,
|
||
};
|
||
}
|
||
|
||
export function renderSkillDocument(id, description, body = "") {
|
||
if (!validSkillName(id)) throw new Error("invalid skill name");
|
||
const desc = String(description || "").trim() || id;
|
||
const text = String(body || "").replace(/^\uFEFF/, "");
|
||
const parts = splitFrontmatter(text);
|
||
const inner = parts ? parts.body : text;
|
||
return `---\nname: ${id}\ndescription: ${yamlScalar(desc.slice(0, MAX_DESCRIPTION))}\n---\n\n${inner.replace(/^\r?\n/, "")}`;
|
||
}
|
||
|
||
function upsertYamlLine(head, key, value) {
|
||
const line = `${key}: ${yamlScalar(value)}`;
|
||
const re = new RegExp(`^${key}:\\s*.*$`, "m");
|
||
if (re.test(head)) return head.replace(re, line);
|
||
return `${line}\n${String(head || "").replace(/^\r?\n/, "")}`;
|
||
}
|
||
|
||
/** 保留官方 whenToUse / invocation / metadata,只改 name 与 description。 */
|
||
export function mergeSkillDocument(id, description, raw = "") {
|
||
const parsed = parseSkillDocument(raw);
|
||
const desc = String(description || "").trim() || (parsed.ok ? parsed.description : "") || id;
|
||
if (!parsed.ok) return renderSkillDocument(id, desc, raw);
|
||
const head = upsertYamlLine(upsertYamlLine(parsed.head, "name", id), "description", desc.slice(0, MAX_DESCRIPTION));
|
||
return `---\n${head.replace(/^\r?\n/, "").replace(/\r?\n$/, "")}\n---\n\n${parsed.body.replace(/^\r?\n/, "")}`;
|
||
}
|
||
|
||
export function formatSize(bytes) {
|
||
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
const kb = bytes / 1024;
|
||
return `${kb >= 100 ? Math.round(kb) : kb.toFixed(1)} KB`;
|
||
}
|
||
|
||
async function pathExists(fp) {
|
||
try {
|
||
await fsp.access(fp);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function skillFromFile(id, fp, kind) {
|
||
const content = await fsp.readFile(fp, "utf8");
|
||
const parsed = parseSkillDocument(content);
|
||
return {
|
||
id,
|
||
kind,
|
||
path: fp,
|
||
content,
|
||
description: parsed.ok ? parsed.description : "",
|
||
whenToUse: parsed.ok ? parsed.whenToUse : "",
|
||
modelInvocable: parsed.ok ? parsed.modelInvocable : false,
|
||
userInvocable: parsed.ok ? parsed.userInvocable : false,
|
||
valid: parsed.ok,
|
||
error: parsed.ok ? null : parsed.error,
|
||
size: Buffer.byteLength(content, "utf8"),
|
||
};
|
||
}
|
||
|
||
export async function readSkill(dshHome, id) {
|
||
if (!validSkillName(id)) throw new Error(`invalid skill name: ${id}`);
|
||
const bundle = skillBundleFile(dshHome, id);
|
||
if (await pathExists(bundle)) return skillFromFile(id, bundle, "bundle");
|
||
const flat = skillFlatFile(dshHome, id);
|
||
if (await pathExists(flat)) return skillFromFile(id, flat, "flat");
|
||
const dir = skillsDir(dshHome);
|
||
let entries = [];
|
||
try {
|
||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||
} catch {
|
||
return null;
|
||
}
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory() || entry.name === ".system") continue;
|
||
const fp = path.join(dir, entry.name, "SKILL.md");
|
||
if (!(await pathExists(fp))) continue;
|
||
const parsed = parseSkillDocument(await fsp.readFile(fp, "utf8"));
|
||
if (parsed.ok && parsed.name === id) return skillFromFile(id, fp, "bundle");
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export async function listSkills(dshHome) {
|
||
const dir = skillsDir(dshHome);
|
||
const out = [];
|
||
let entries = [];
|
||
try {
|
||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||
} catch {
|
||
return out;
|
||
}
|
||
const seen = new Set();
|
||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||
if (entry.name === ".system") continue;
|
||
if (entry.isDirectory()) {
|
||
const fp = path.join(dir, entry.name, "SKILL.md");
|
||
if (!(await pathExists(fp))) continue;
|
||
const parsed = parseSkillDocument(await fsp.readFile(fp, "utf8"));
|
||
const id = parsed.ok ? parsed.name : (validSkillName(entry.name) ? entry.name : "");
|
||
if (!id || seen.has(id)) continue;
|
||
seen.add(id);
|
||
out.push(await skillFromFile(id, fp, "bundle"));
|
||
continue;
|
||
}
|
||
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "SKILL.md") continue;
|
||
const fallback = entry.name.slice(0, -3);
|
||
const fp = path.join(dir, entry.name);
|
||
const parsed = parseSkillDocument(await fsp.readFile(fp, "utf8"));
|
||
const id = parsed.ok ? parsed.name : (validSkillName(fallback) ? fallback : "");
|
||
if (!id || seen.has(id)) continue;
|
||
seen.add(id);
|
||
out.push(await skillFromFile(id, fp, "flat"));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async function copyDir(src, dest) {
|
||
await fsp.mkdir(dest, { recursive: true });
|
||
for (const entry of await fsp.readdir(src, { withFileTypes: true })) {
|
||
if (SKIP_COPY_NAMES.has(entry.name) || entry.name.startsWith("._")) continue;
|
||
if (entry.isSymbolicLink()) continue;
|
||
const from = path.join(src, entry.name);
|
||
const to = path.join(dest, entry.name);
|
||
assertInside(dest, to);
|
||
if (entry.isDirectory()) await copyDir(from, to);
|
||
else if (entry.isFile()) await fsp.copyFile(from, to);
|
||
}
|
||
}
|
||
|
||
async function backupExisting(dshHome, id) {
|
||
const existing = await readSkill(dshHome, id).catch(() => null);
|
||
const root = skillsDir(dshHome);
|
||
const bundleDir = existing?.kind === "bundle" ? path.dirname(existing.path) : skillBundleDir(dshHome, id);
|
||
const flat = existing?.kind === "flat" ? existing.path : skillFlatFile(dshHome, id);
|
||
const hasBundle = await pathExists(bundleDir);
|
||
const hasFlat = await pathExists(flat);
|
||
if (!hasBundle && !hasFlat) return null;
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||
const uniq = `${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
|
||
const dest = path.join(skillBackupDir(dshHome), `${id}_${stamp}_${uniq}`);
|
||
assertInside(path.join(dshHome, "dsh-purge-backups"), dest);
|
||
await fsp.mkdir(dest, { recursive: true });
|
||
if (hasBundle) {
|
||
assertInside(root, bundleDir);
|
||
await copyDir(bundleDir, path.join(dest, id));
|
||
}
|
||
if (hasFlat) {
|
||
assertInside(root, flat);
|
||
await fsp.copyFile(flat, path.join(dest, `${id}.md`));
|
||
}
|
||
return dest;
|
||
}
|
||
|
||
async function removeOfficialSkill(dshHome, id) {
|
||
const existing = await readSkill(dshHome, id).catch(() => null);
|
||
const root = skillsDir(dshHome);
|
||
const bundleDir = existing?.kind === "bundle" ? path.dirname(existing.path) : skillBundleDir(dshHome, id);
|
||
const flat = existing?.kind === "flat" ? existing.path : skillFlatFile(dshHome, id);
|
||
if (await pathExists(bundleDir)) {
|
||
assertInside(root, bundleDir);
|
||
await fsp.rm(bundleDir, { recursive: true, force: true });
|
||
}
|
||
if (await pathExists(flat)) {
|
||
assertInside(root, flat);
|
||
await fsp.rm(flat, { force: true });
|
||
}
|
||
const canonical = skillBundleDir(dshHome, id);
|
||
if (canonical !== bundleDir && await pathExists(canonical)) {
|
||
assertInside(root, canonical);
|
||
await fsp.rm(canonical, { recursive: true, force: true });
|
||
}
|
||
}
|
||
|
||
export async function saveSkill(dshHome, id, content, description) {
|
||
if (!validSkillName(id)) throw new Error(`invalid skill name: ${id}`);
|
||
const parsed = parseSkillDocument(content);
|
||
const desc = String(description || "").trim() || (parsed.ok ? parsed.description : "");
|
||
const document = mergeSkillDocument(id, desc, String(content || ""));
|
||
if (Buffer.byteLength(document, "utf8") > MAX_SKILL_BYTES) {
|
||
throw new Error("skill too large (max 256KB)");
|
||
}
|
||
const check = parseSkillDocument(document);
|
||
if (!check.ok) throw new Error(check.error);
|
||
const root = skillsDir(dshHome);
|
||
const dest = skillBundleFile(dshHome, id);
|
||
assertInside(root, dest);
|
||
const backup = await backupExisting(dshHome, id);
|
||
try {
|
||
await removeOfficialSkill(dshHome, id);
|
||
await fsp.mkdir(skillBundleDir(dshHome, id), { recursive: true });
|
||
await atomicWriteFile(dest, document);
|
||
} catch (err) {
|
||
try { await restoreSkillFromBackup(dshHome, id, backup); }
|
||
catch (restoreErr) {
|
||
console.warn(
|
||
"[dsh-purge] saveSkill restore failed:",
|
||
restoreErr && restoreErr.message ? restoreErr.message : restoreErr,
|
||
);
|
||
}
|
||
throw err;
|
||
}
|
||
return {
|
||
id,
|
||
path: dest,
|
||
description: check.description,
|
||
backup,
|
||
size: Buffer.byteLength(document, "utf8"),
|
||
content: document,
|
||
};
|
||
}
|
||
|
||
export async function deleteSkill(dshHome, id) {
|
||
if (!validSkillName(id)) throw new Error(`invalid skill name: ${id}`);
|
||
const existing = await readSkill(dshHome, id);
|
||
if (!existing) throw new Error(`skill not found: ${id}`);
|
||
const backup = await backupExisting(dshHome, id);
|
||
await removeOfficialSkill(dshHome, id);
|
||
return { id, backup, path: existing.path };
|
||
}
|
||
|
||
function kebabFromLabel(name) {
|
||
const base = String(name || "")
|
||
.replace(/\.(zip|tgz|tar\.gz|tar|skill)$/i, "")
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, "-")
|
||
.replace(/^-+|-+$/g, "");
|
||
return validSkillName(base) ? base : "";
|
||
}
|
||
|
||
function isUnsafeArchiveEntry(name) {
|
||
const rel = String(name || "").replace(/\\/g, "/").replace(/^\.?\//, "").replace(/\/+$/, "");
|
||
if (!rel) return false;
|
||
if (rel.startsWith("/") || /^[A-Za-z]:/.test(rel)) return true;
|
||
return rel.split("/").some((part) => part === ".." || part === "");
|
||
}
|
||
|
||
function splitLines(text) {
|
||
return String(text || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||
}
|
||
|
||
function listArchiveEntries(archivePath) {
|
||
const result = spawnSync("tar", ["-tf", archivePath], {
|
||
windowsHide: true,
|
||
encoding: "utf8",
|
||
timeout: 15000,
|
||
});
|
||
if (result.status === 0) return splitLines(result.stdout);
|
||
if (/\.zip$/i.test(archivePath)) {
|
||
const unzip = spawnSync("unzip", ["-Z1", archivePath], {
|
||
windowsHide: true,
|
||
encoding: "utf8",
|
||
timeout: 15000,
|
||
});
|
||
if (unzip.status === 0) return splitLines(unzip.stdout);
|
||
try {
|
||
return listZipFile(archivePath);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function extractArchive(archivePath, dest) {
|
||
const entries = listArchiveEntries(archivePath);
|
||
if (entries && entries.some(isUnsafeArchiveEntry)) {
|
||
throw new Error("压缩包路径不合法");
|
||
}
|
||
if (entries && entries.length > MAX_IMPORT_FILES) {
|
||
throw new Error("压缩包里文件太多");
|
||
}
|
||
if (/\.zip$/i.test(archivePath)) {
|
||
try {
|
||
extractZipArchive(archivePath, dest);
|
||
return;
|
||
} catch (error) {
|
||
throw new Error(String(error && error.message ? error.message : error));
|
||
}
|
||
}
|
||
const result = spawnSync("tar", ["-xf", archivePath, "-C", dest], {
|
||
windowsHide: true,
|
||
encoding: "utf8",
|
||
timeout: 30000,
|
||
});
|
||
if (result.status === 0) return;
|
||
const detail = String(result.stderr || result.stdout || "").trim();
|
||
throw new Error(detail || "无法解压,请改用文件夹导入");
|
||
}
|
||
|
||
async function walkFiles(root, onFile, depth = 0) {
|
||
if (depth > 6) return;
|
||
let entries = [];
|
||
try {
|
||
entries = await fsp.readdir(root, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
for (const entry of entries) {
|
||
if (entry.name === "." || entry.name === ".." || entry.name === ".system" || entry.name === "__MACOSX" || entry.name === ".DS_Store") continue;
|
||
if (entry.name.startsWith("._")) continue;
|
||
const full = path.join(root, entry.name);
|
||
assertInside(root, full);
|
||
if (entry.isDirectory()) await walkFiles(full, onFile, depth + 1);
|
||
else if (entry.isFile()) await onFile(full);
|
||
}
|
||
}
|
||
|
||
export async function findSkillRoots(root) {
|
||
const dirs = [];
|
||
const flats = [];
|
||
async function visit(dir, depth) {
|
||
if (depth > 4) return;
|
||
let entries = [];
|
||
try {
|
||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
if (entries.some((entry) => entry.isFile() && entry.name === "SKILL.md")) {
|
||
dirs.push(dir);
|
||
return;
|
||
}
|
||
for (const entry of entries) {
|
||
if (entry.name === "." || entry.name === "..") continue;
|
||
if (entry.isFile() && entry.name.endsWith(".md") && entry.name !== "SKILL.md") {
|
||
const raw = await fsp.readFile(path.join(dir, entry.name), "utf8");
|
||
if (parseSkillDocument(raw).ok) flats.push(path.join(dir, entry.name));
|
||
}
|
||
}
|
||
for (const entry of entries) {
|
||
if (!entry.isDirectory()) continue;
|
||
if (entry.name === "." || entry.name === ".." || entry.name === ".system" || entry.name === "__MACOSX") continue;
|
||
const next = path.join(dir, entry.name);
|
||
assertInside(root, next);
|
||
await visit(next, depth + 1);
|
||
}
|
||
}
|
||
await visit(root, 0);
|
||
return { dirs, flats };
|
||
}
|
||
|
||
async function restoreSkillFromBackup(dshHome, id, backup) {
|
||
if (!backup) return;
|
||
const root = skillsDir(dshHome);
|
||
const bundleSrc = path.join(backup, id);
|
||
const flatSrc = path.join(backup, `${id}.md`);
|
||
try {
|
||
if (await pathExists(bundleSrc)) {
|
||
const dest = skillBundleDir(dshHome, id);
|
||
assertInside(root, dest);
|
||
await fsp.rm(dest, { recursive: true, force: true }).catch(() => {});
|
||
await copyDir(bundleSrc, dest);
|
||
}
|
||
if (await pathExists(flatSrc)) {
|
||
const dest = skillFlatFile(dshHome, id);
|
||
assertInside(root, dest);
|
||
await fsp.copyFile(flatSrc, dest);
|
||
}
|
||
} catch {
|
||
// 回滚失败时保留 backup 路径给调用方。
|
||
}
|
||
}
|
||
|
||
async function installBundleDir(dshHome, srcDir, fallbackId) {
|
||
assertImportSourceOutsideSkills(dshHome, srcDir);
|
||
const mdPath = path.join(srcDir, "SKILL.md");
|
||
const raw = await fsp.readFile(mdPath, "utf8");
|
||
const parsed = parseSkillDocument(raw);
|
||
const id = parsed.ok ? parsed.name : kebabFromLabel(fallbackId || path.basename(srcDir));
|
||
if (!validSkillName(id)) throw new Error("压缩包/文件夹里没有合法的 Skill 名称");
|
||
const dest = skillBundleDir(dshHome, id);
|
||
assertInside(skillsDir(dshHome), dest);
|
||
if (pathsOverlap(srcDir, dest)) {
|
||
throw new Error("不能从目标技能目录自身导入,请换一个源目录");
|
||
}
|
||
const backup = await backupExisting(dshHome, id);
|
||
await removeOfficialSkill(dshHome, id);
|
||
try {
|
||
await copyDir(srcDir, dest);
|
||
const destMd = path.join(dest, "SKILL.md");
|
||
if (!(await pathExists(destMd))) throw new Error("导入后缺少 SKILL.md");
|
||
if (parsed.ok && parsed.name !== id) {
|
||
await fsp.writeFile(destMd, mergeSkillDocument(id, parsed.description, raw), "utf8");
|
||
} else if (!parsed.ok && !String(raw).replace(/^\uFEFF/, "").startsWith("---")) {
|
||
await fsp.writeFile(destMd, renderSkillDocument(id, id, raw), "utf8");
|
||
}
|
||
return { id, path: destMd, backup, description: parsed.ok ? parsed.description : id };
|
||
} catch (err) {
|
||
await fsp.rm(dest, { recursive: true, force: true }).catch(() => {});
|
||
await restoreSkillFromBackup(dshHome, id, backup);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async function installFlatFile(dshHome, srcFile, fallbackId) {
|
||
assertImportSourceOutsideSkills(dshHome, srcFile);
|
||
const raw = await fsp.readFile(srcFile, "utf8");
|
||
const parsed = parseSkillDocument(raw);
|
||
const id = parsed.ok ? parsed.name : kebabFromLabel(fallbackId || path.basename(srcFile, ".md"));
|
||
if (!parsed.ok) throw new Error(parsed.error);
|
||
return saveSkill(dshHome, id, raw, parsed.description);
|
||
}
|
||
|
||
async function installFoundSkills(dshHome, root, fallbackId) {
|
||
assertImportSourceOutsideSkills(dshHome, root);
|
||
const found = await findSkillRoots(root);
|
||
const imported = [];
|
||
for (const dir of found.dirs) {
|
||
imported.push(await installBundleDir(dshHome, dir, fallbackId || path.basename(dir)));
|
||
}
|
||
if (!imported.length) {
|
||
for (const file of found.flats) {
|
||
imported.push(await installFlatFile(dshHome, file, fallbackId));
|
||
}
|
||
}
|
||
if (!imported.length) throw new Error("未找到 SKILL.md 或合法的 Skill 文件");
|
||
return imported;
|
||
}
|
||
|
||
export async function importArchive(dshHome, buffer, filename = "skill.zip") {
|
||
const bytes = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||
if (!bytes.length) throw new Error("压缩包是空的");
|
||
if (bytes.length > MAX_ARCHIVE_BYTES) throw new Error("压缩包太大(最大 12MB)");
|
||
const temp = await fsp.mkdtemp(path.join(os.tmpdir(), "dshp-skill-"));
|
||
const archive = path.join(temp, path.basename(filename) || "skill.zip");
|
||
const unpacked = path.join(temp, "unpacked");
|
||
try {
|
||
await fsp.mkdir(unpacked, { recursive: true });
|
||
await fsp.writeFile(archive, bytes);
|
||
extractArchive(archive, unpacked);
|
||
let total = 0;
|
||
await walkFiles(unpacked, async (fp) => {
|
||
// 非 ENOENT 的 fs 错误要 rethrow,否则磁盘损坏 / EACCES / EMFILE 都被吞,
|
||
// size 累计漏算,导入看似成功但产物坏。ENOENT 可能是枚举后被外部删了,安全跳过。
|
||
let st;
|
||
try { st = await fsp.stat(fp); }
|
||
catch (err) {
|
||
if (err && err.code === "ENOENT") return;
|
||
throw err;
|
||
}
|
||
total += st.size;
|
||
if (total > MAX_UNPACKED_BYTES) {
|
||
throw new Error(`压缩包解压后超过 ${Math.floor(MAX_UNPACKED_BYTES / 1024 / 1024)}MB`);
|
||
}
|
||
});
|
||
const imported = await installFoundSkills(dshHome, unpacked, kebabFromLabel(filename));
|
||
return { imported: imported.map((item) => toPublicSkill(dshHome, item)) };
|
||
} finally {
|
||
await fsp.rm(temp, { recursive: true, force: true }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
export async function importFileTree(dshHome, files, fallbackId = "") {
|
||
if (!Array.isArray(files) || !files.length) throw new Error("文件夹是空的");
|
||
if (files.length > MAX_IMPORT_FILES) throw new Error("文件夹里文件太多");
|
||
const temp = await fsp.mkdtemp(path.join(os.tmpdir(), "dshp-skill-"));
|
||
try {
|
||
let total = 0;
|
||
for (const file of files) {
|
||
const rel = String(file?.path || file?.name || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||
if (!rel || rel.includes("..") || path.isAbsolute(rel) || isUnsafeArchiveEntry(rel)) {
|
||
throw new Error("文件夹路径不合法");
|
||
}
|
||
const dest = path.join(temp, rel);
|
||
assertInside(temp, dest);
|
||
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
||
const text = typeof file.content === "string" ? file.content : "";
|
||
const raw = file.encoding === "base64" ? Buffer.from(text, "base64") : Buffer.from(text, "utf8");
|
||
total += raw.length;
|
||
if (raw.length > MAX_ARCHIVE_BYTES || total > MAX_ARCHIVE_BYTES) throw new Error("文件太大");
|
||
await fsp.writeFile(dest, raw);
|
||
}
|
||
const imported = await installFoundSkills(dshHome, temp, fallbackId);
|
||
return { imported: imported.map((item) => toPublicSkill(dshHome, item)) };
|
||
} finally {
|
||
await fsp.rm(temp, { recursive: true, force: true }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
export async function importFromPath(dshHome, sourcePath) {
|
||
const src = path.resolve(String(sourcePath || ""));
|
||
const stat = await fsp.stat(src);
|
||
if (stat.isDirectory()) {
|
||
const imported = await installFoundSkills(dshHome, src, kebabFromLabel(path.basename(src)));
|
||
return { imported: imported.map((item) => toPublicSkill(dshHome, item)) };
|
||
}
|
||
if (stat.isFile()) {
|
||
const name = path.basename(src);
|
||
if (/\.(zip|tgz|tar\.gz|tar|skill)$/i.test(name)) {
|
||
return importArchive(dshHome, await fsp.readFile(src), name);
|
||
}
|
||
return { imported: [toPublicSkill(dshHome, await installFlatFile(dshHome, src, kebabFromLabel(name)))] };
|
||
}
|
||
throw new Error("只能导入压缩包或文件夹");
|
||
}
|
||
|
||
export async function skillsStatus(dshHome) {
|
||
const listed = await listSkills(dshHome);
|
||
return {
|
||
skills_dir: "$DSH_HOME/skills",
|
||
skills: listed.map((item) => ({
|
||
id: item.id,
|
||
description: item.description,
|
||
kind: item.kind,
|
||
valid: item.valid,
|
||
error: item.error,
|
||
size: item.size,
|
||
path: displayUnderHome(dshHome, item.path),
|
||
})),
|
||
};
|
||
}
|