@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 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.20`.
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.20`。
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
@@ -593,6 +593,7 @@ window.__ModuleLoader__.load({
593
593
  inflightRef.current = false;
594
594
  setBusy(null);
595
595
  setUploadOpen(false);
596
+ setSelected(null);
596
597
  setImportResult({ error: error });
597
598
  });
598
599
  }
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 isInsideRoot(root, path) {
100
- return isSameOrDescendant(await resolvedPath(root), await resolvedPath(path));
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 isInsideRoot(rootPath, bundleDoc)) {
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 isInsideRoot(rootPath, flatDoc)) {
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 isInsideRoot(root, docPath))) continue;
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 isInsideRoot(root, docPath))) continue;
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
- try {
345
- const st = await fs.stat(alternateDocPath);
346
- if (st.isFile()) targets.push({ path: alternatePath, recursive: resolved.kind !== "bundle" });
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
- try {
366
- const sk = join(source, "SKILL.md");
367
- const skSt = await fs.stat(sk);
368
- if (skSt.isFile()) {
369
- return { kind: "single", rawName: basename(source), kebab: toKebab(basename(source)), source, isDir: true, skillFile: sk };
370
- }
371
- } catch {}
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
- try {
391
- if (it.isDirectory()) {
392
- const sk = join(dir, it.name, "SKILL.md");
393
- const st = await fs.stat(sk);
394
- if (st.isFile()) {
395
- out.push({ source: join(dir, it.name), kebab: toKebab(it.name), rawName: it.name, isDir: true });
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
- } catch {
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
- if (failed.length && pending.length === 0 && conflicts.length === 0) return failureResult();
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
- res.end(body);
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
- res.writeHead(path === "/api/dsh-skills-manager/state" ? 200 : 405, {
201
- "content-type": "application/json; charset=utf-8",
202
- "content-length": 0,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-skills-manager",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "NPM-installable DSH Web plugin for managing local skills and viewing shared Agent skills safely.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {