@michengai/dsh-skills-manager 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/client.js +1 -0
- package/lib/core.js +47 -30
- package/lib/index.js +10 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,7 +115,7 @@ dsh plugin --profile web add @michengai/dsh-skills-manager@latest --registry=htt
|
|
|
115
115
|
dsh --profile web --dump-config
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
To pin a release, replace `@latest` with a version such as `@0.1.
|
|
118
|
+
To pin a release, replace `@latest` with a version such as `@0.1.22`.
|
|
119
119
|
|
|
120
120
|
The configuration output should contain `skills-manager`. Restart DSH Web and hard-refresh the browser. Do not copy client files manually: `dsh plugin add` also applies `cordis.patch.yml`.
|
|
121
121
|
|
package/README.zh-CN.md
CHANGED
|
@@ -115,7 +115,7 @@ dsh plugin --profile web add @michengai/dsh-skills-manager@latest --registry=htt
|
|
|
115
115
|
dsh --profile web --dump-config
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
需要钉死某一版时,把 `@latest` 换成具体版本,例如 `@0.1.
|
|
118
|
+
需要钉死某一版时,把 `@latest` 换成具体版本,例如 `@0.1.22`。
|
|
119
119
|
|
|
120
120
|
配置输出中应包含 `skills-manager`。安装后重启 DSH Web 并在浏览器硬刷新。不要手工复制客户端文件,`dsh plugin add` 会同时应用 `cordis.patch.yml`。
|
|
121
121
|
|
package/lib/client.js
CHANGED
package/lib/core.js
CHANGED
|
@@ -95,9 +95,9 @@ async function pathsOverlap(a, b) {
|
|
|
95
95
|
return isSameOrDescendant(left, right) || isSameOrDescendant(right, left);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/**
|
|
99
|
-
async function
|
|
100
|
-
return isSameOrDescendant(
|
|
98
|
+
/** 预解析技能根后的根内校验,供逐条目扫描复用同一次 realpath,减少重复 IO。 */
|
|
99
|
+
async function isInsideResolvedRoot(rootReal, path) {
|
|
100
|
+
return isSameOrDescendant(rootReal, await resolvedPath(path));
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
async function lstatOrNull(path) {
|
|
@@ -248,18 +248,19 @@ export async function resolveEntry(root, name) {
|
|
|
248
248
|
const bundlePath = entryPath(root, name);
|
|
249
249
|
if (bundlePath === null) return null;
|
|
250
250
|
const rootPath = resolve(root);
|
|
251
|
+
const rootReal = await resolvedPath(rootPath);
|
|
251
252
|
const bundleStat = await lstatOrNull(bundlePath);
|
|
252
253
|
if (bundleStat && bundleStat.isDirectory() && !bundleStat.isSymbolicLink()) {
|
|
253
254
|
const bundleDoc = join(bundlePath, "SKILL.md");
|
|
254
255
|
const docStat = await lstatOrNull(bundleDoc);
|
|
255
|
-
if (docStat && docStat.isFile() && !docStat.isSymbolicLink() && await
|
|
256
|
+
if (docStat && docStat.isFile() && !docStat.isSymbolicLink() && await isInsideResolvedRoot(rootReal, bundleDoc)) {
|
|
256
257
|
return { kind: "bundle", docPath: bundleDoc, entryPath: bundlePath };
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
const flatDoc = resolve(rootPath, `${name}.md`);
|
|
260
261
|
if (!isSameOrDescendant(rootPath, flatDoc) || rootPath === flatDoc) return null;
|
|
261
262
|
const flatStat = await lstatOrNull(flatDoc);
|
|
262
|
-
if (flatStat && flatStat.isFile() && !flatStat.isSymbolicLink() && await
|
|
263
|
+
if (flatStat && flatStat.isFile() && !flatStat.isSymbolicLink() && await isInsideResolvedRoot(rootReal, flatDoc)) {
|
|
263
264
|
return { kind: "flat", docPath: flatDoc, entryPath: flatDoc };
|
|
264
265
|
}
|
|
265
266
|
return null;
|
|
@@ -292,20 +293,21 @@ export async function scanEntries(root) {
|
|
|
292
293
|
return { exists: false, entries: [] };
|
|
293
294
|
}
|
|
294
295
|
const byName = new Map();
|
|
296
|
+
const rootReal = await resolvedPath(root);
|
|
295
297
|
for (const it of items) {
|
|
296
298
|
try {
|
|
297
299
|
if (it.isSymbolicLink()) continue;
|
|
298
300
|
if (it.isDirectory() && entryPath(root, it.name) !== null) {
|
|
299
301
|
const docPath = join(root, it.name, "SKILL.md");
|
|
300
302
|
const st = await lstatOrNull(docPath);
|
|
301
|
-
if (!st || !st.isFile() || st.isSymbolicLink() || !(await
|
|
303
|
+
if (!st || !st.isFile() || st.isSymbolicLink() || !(await isInsideResolvedRoot(rootReal, docPath))) continue;
|
|
302
304
|
const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
303
305
|
byName.set(it.name, entryOf(it.name, "bundle", docPath, doc));
|
|
304
306
|
} else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md" && entryPath(root, it.name.slice(0, -3)) !== null) {
|
|
305
307
|
const skillName = it.name.slice(0, -3);
|
|
306
308
|
if (byName.has(skillName)) continue;
|
|
307
309
|
const docPath = join(root, it.name);
|
|
308
|
-
if (!(await
|
|
310
|
+
if (!(await isInsideResolvedRoot(rootReal, docPath))) continue;
|
|
309
311
|
const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
|
|
310
312
|
byName.set(skillName, entryOf(skillName, "flat", docPath, doc));
|
|
311
313
|
}
|
|
@@ -341,10 +343,9 @@ export async function deleteSkill(root, name, log) {
|
|
|
341
343
|
const alternatePath = resolved.kind === "bundle" ? resolve(root, `${name}.md`) : bundlePath;
|
|
342
344
|
const alternateDocPath = resolved.kind === "bundle" ? alternatePath : join(alternatePath, "SKILL.md");
|
|
343
345
|
const targets = [{ path: resolved.entryPath, recursive: resolved.kind === "bundle" }];
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
} catch {}
|
|
346
|
+
// 链接形态的伴随条目也要清理本体;fs.rm 只删除链接本身,不会跟随写到根外。
|
|
347
|
+
const alternateStat = await lstatOrNull(alternateDocPath);
|
|
348
|
+
if (alternateStat && (alternateStat.isFile() || alternateStat.isSymbolicLink())) targets.push({ path: alternatePath, recursive: resolved.kind !== "bundle" });
|
|
348
349
|
for (const target of targets) await fs.rm(target.path, { recursive: target.recursive, force: true });
|
|
349
350
|
if (log) log("delete", `删除 ${targets.map((target) => target.path).join("、")}`);
|
|
350
351
|
return { name };
|
|
@@ -362,13 +363,13 @@ async function analyzeSource(source) {
|
|
|
362
363
|
}
|
|
363
364
|
if (st.isSymbolicLink()) return { kind: "none", error: `不支持包含符号链接的 skill 来源: ${source}`, code: "error.source.symlink", params: { path: source } };
|
|
364
365
|
if (st.isDirectory()) {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
}
|
|
366
|
+
const sk = join(source, "SKILL.md");
|
|
367
|
+
const skSt = await lstatOrNull(sk);
|
|
368
|
+
// 预检与实际导入口径一致:SKILL.md 本身是链接时直接拒绝,避免 dry-run 通过、正式导入才失败。
|
|
369
|
+
if (skSt && skSt.isSymbolicLink()) return { kind: "none", error: `不支持包含符号链接的 skill 来源: ${sk}`, code: "error.source.symlink", params: { path: sk } };
|
|
370
|
+
if (skSt && skSt.isFile()) {
|
|
371
|
+
return { kind: "single", rawName: basename(source), kebab: toKebab(basename(source)), source, isDir: true, skillFile: sk };
|
|
372
|
+
}
|
|
372
373
|
return { kind: "batch", rawName: basename(source), source, isDir: true };
|
|
373
374
|
}
|
|
374
375
|
if (st.isFile() && source.toLowerCase().endsWith(".md")) {
|
|
@@ -387,18 +388,16 @@ async function collectCandidates(dir) {
|
|
|
387
388
|
const out = [];
|
|
388
389
|
for (const it of items) {
|
|
389
390
|
if (it.isSymbolicLink()) throw codedError(`不支持包含符号链接的 skill 来源: ${join(dir, it.name)}`, "error.source.symlink", { path: join(dir, it.name) });
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
} else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md") {
|
|
398
|
-
out.push({ source: join(dir, it.name), kebab: toKebab(it.name.slice(0, -3)), rawName: it.name.slice(0, -3), isDir: false });
|
|
391
|
+
if (it.isDirectory()) {
|
|
392
|
+
const sk = join(dir, it.name, "SKILL.md");
|
|
393
|
+
// lstatOrNull 吞掉 IO 异常(返回 null 即跳过);symlink 必须抛出,不能被“跳过”逻辑掩盖。
|
|
394
|
+
const st = await lstatOrNull(sk);
|
|
395
|
+
if (st && st.isSymbolicLink()) throw codedError(`不支持包含符号链接的 skill 来源: ${sk}`, "error.source.symlink", { path: sk });
|
|
396
|
+
if (st && st.isFile()) {
|
|
397
|
+
out.push({ source: join(dir, it.name), kebab: toKebab(it.name), rawName: it.name, isDir: true });
|
|
399
398
|
}
|
|
400
|
-
}
|
|
401
|
-
|
|
399
|
+
} else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md") {
|
|
400
|
+
out.push({ source: join(dir, it.name), kebab: toKebab(it.name.slice(0, -3)), rawName: it.name.slice(0, -3), isDir: false });
|
|
402
401
|
}
|
|
403
402
|
}
|
|
404
403
|
return out;
|
|
@@ -426,6 +425,22 @@ function temporaryPath(target, kind) {
|
|
|
426
425
|
return join(dirname(target), `.${basename(target)}.dssm-${kind}-${randomUUID()}`);
|
|
427
426
|
}
|
|
428
427
|
|
|
428
|
+
/** dry-run 预检执行与正式导入相同的符号链接/深度检查,预检失败即结论,不再进入覆盖确认。
|
|
429
|
+
* 预检与实导之间来源被替换的竞态仍由实导阶段的复制后校验兜底。 */
|
|
430
|
+
async function preflightCandidates(pending, conflicts, failed) {
|
|
431
|
+
for (const group of [pending, conflicts]) {
|
|
432
|
+
for (let i = group.length - 1; i >= 0; i--) {
|
|
433
|
+
const candidate = group[i];
|
|
434
|
+
try {
|
|
435
|
+
await assertNoSymbolicLinks(candidate.source);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
failed.push(attachCode({ source: candidate.source, error: String(error && error.message ? error.message : error) }, error));
|
|
438
|
+
group.splice(i, 1);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
429
444
|
/** 先复制到同目录临时路径,复制失败时不触碰现有技能。 */
|
|
430
445
|
async function copyToTemporary(source, target, isDir) {
|
|
431
446
|
const temp = temporaryPath(target, "stage");
|
|
@@ -565,7 +580,9 @@ export async function importSkill(source, log, options = {}) {
|
|
|
565
580
|
if (pending.length === 0 && conflicts.length === 0) return failureResult();
|
|
566
581
|
|
|
567
582
|
if (dryRun) {
|
|
568
|
-
|
|
583
|
+
// 预检即结论:与正式导入同口径执行符号链接/深度检查,避免预检通过、确认覆盖后实导才失败。
|
|
584
|
+
await preflightCandidates(pending, conflicts, failed);
|
|
585
|
+
if (pending.length === 0 && conflicts.length === 0) return failureResult();
|
|
569
586
|
return { kind: analysis.kind, pending, conflicts, failed };
|
|
570
587
|
}
|
|
571
588
|
|
package/lib/index.js
CHANGED
|
@@ -90,7 +90,7 @@ function validateMutationRequest(req) {
|
|
|
90
90
|
return null;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function json(res, code, payload) {
|
|
93
|
+
function json(res, code, payload, headOnly) {
|
|
94
94
|
if (!res || res.writableEnded || res.destroyed) return;
|
|
95
95
|
res.once("error", () => {});
|
|
96
96
|
try {
|
|
@@ -99,23 +99,24 @@ function json(res, code, payload) {
|
|
|
99
99
|
"content-type": "application/json; charset=utf-8",
|
|
100
100
|
"content-length": Buffer.byteLength(body),
|
|
101
101
|
});
|
|
102
|
-
|
|
102
|
+
// HEAD 按 RFC 语义返回与实体一致的 content-length,但不输出 body。
|
|
103
|
+
res.end(headOnly ? undefined : body);
|
|
103
104
|
} catch {
|
|
104
105
|
/* 客户端已断开 */
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/** 成功统一包成 { ok: true, data };核心返回 { ok:false, error } 时透传为 400。 */
|
|
109
|
-
function run(res, task, afterSuccess) {
|
|
110
|
+
function run(res, task, afterSuccess, headOnly) {
|
|
110
111
|
return Promise.resolve().then(task).then((r) => {
|
|
111
|
-
if (r && r.ok === false) json(res, 400, r);
|
|
112
|
+
if (r && r.ok === false) json(res, 400, r, headOnly);
|
|
112
113
|
else {
|
|
113
114
|
try {
|
|
114
115
|
if (afterSuccess) afterSuccess();
|
|
115
116
|
} catch {
|
|
116
117
|
/* 目录刷新失败不影响已完成的文件操作 */
|
|
117
118
|
}
|
|
118
|
-
json(res, 200, { ok: true, data: r });
|
|
119
|
+
json(res, 200, { ok: true, data: r }, headOnly);
|
|
119
120
|
}
|
|
120
121
|
}).catch((e) => {
|
|
121
122
|
try {
|
|
@@ -123,7 +124,7 @@ function run(res, task, afterSuccess) {
|
|
|
123
124
|
ok: false,
|
|
124
125
|
...(e && e.code ? { code: e.code } : {}),
|
|
125
126
|
error: String(e && e.message ? e.message : e),
|
|
126
|
-
});
|
|
127
|
+
}, headOnly);
|
|
127
128
|
} catch {
|
|
128
129
|
/* response already closed */
|
|
129
130
|
}
|
|
@@ -197,12 +198,9 @@ function apply(ctx) {
|
|
|
197
198
|
return run(res, state);
|
|
198
199
|
}
|
|
199
200
|
if (req.method === "HEAD") {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
allow: "GET, HEAD, POST",
|
|
204
|
-
});
|
|
205
|
-
res.end();
|
|
201
|
+
// HEAD 复用 GET/405 的载荷计算 content-length,保持与实体一致的响应头语义。
|
|
202
|
+
if (path === "/api/dsh-skills-manager/state") return run(res, state, undefined, true);
|
|
203
|
+
json(res, 405, { ok: false, code: "error.proto.method", error: `method not allowed: ${req.method}` }, true);
|
|
206
204
|
return;
|
|
207
205
|
}
|
|
208
206
|
if (req.method !== "POST") {
|
package/package.json
CHANGED