@michengai/dsh-skills-manager 0.1.8 → 0.1.9

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/lib/client.js CHANGED
@@ -9,9 +9,9 @@ window.__ModuleLoader__.load({
9
9
  var pickDirectory = null;
10
10
  var MUTATION_HEADERS = { "content-type": "application/json", "x-dsh-skills-manager": "1" };
11
11
 
12
- // 词典命名空间:注册进 DSH locale 服务,渲染机制按该命名空间把 t 注入组件 props
12
+ // 词典命名空间必须与 settings.section locale 一致,宿主据此注入 t。
13
13
  var NS = "skills-manager";
14
- // 双语词典:zh 与现有硬编码文案逐字一致,保证中文用户无感知;en 为通顺英文。
14
+ // 宿主支持运行时切换语言,所有用户可见文案必须同时维护 zh/en
15
15
  var DICT = {
16
16
  zh: {
17
17
  "title": "技能",
@@ -71,7 +71,6 @@ window.__ModuleLoader__.load({
71
71
  "confirm.overwrite.desc": "将覆盖现有插件:{names}",
72
72
  "confirm.delete.title": "删除插件?",
73
73
  "confirm.delete.desc": "“{name}”将从 DSH 技能目录中永久删除,无法恢复。",
74
- // Step 2 业务错误码:zh 值保持与 core 原有中文文案逐字一致({action} 由 action.* 映射后替换)。
75
74
  "error.root.readonly": "公共 Agent 技能目录不允许{action}",
76
75
  "error.skill.notFound": "技能不存在: {name}",
77
76
  "error.skill.noFrontmatter": "技能缺少完整 frontmatter,无法{action}: {name}",
@@ -90,6 +89,7 @@ window.__ModuleLoader__.load({
90
89
  "error.proto.unknownAction": "未知操作",
91
90
  "error.proto.bodyTooLarge": "请求体过大",
92
91
  "error.proto.invalidJson": "请求体不是合法 JSON",
92
+ "error.proto.nonJson": "服务端返回非 JSON 响应(HTTP {status})",
93
93
  "action.enable": "启用",
94
94
  "action.disable": "停用",
95
95
  "action.delete": "删除",
@@ -172,6 +172,7 @@ window.__ModuleLoader__.load({
172
172
  "error.proto.unknownAction": "Unknown action",
173
173
  "error.proto.bodyTooLarge": "Request body too large",
174
174
  "error.proto.invalidJson": "Invalid JSON request body",
175
+ "error.proto.nonJson": "Server returned a non-JSON response (HTTP {status})",
175
176
  "action.enable": "enable",
176
177
  "action.disable": "disable",
177
178
  "action.delete": "deleting",
@@ -197,36 +198,40 @@ window.__ModuleLoader__.load({
197
198
  `;
198
199
 
199
200
  function callApi(path, options) {
200
- return fetch("/api/dsh-skills-manager" + path, options).then(function (response) {
201
- return response.json().then(function (payload) {
202
- if (!payload.ok) {
203
- var error = new Error(payload.error || ("HTTP " + response.status));
204
- // 透传业务错误码与失败明细,供 translateError 按词典翻译。
205
- if (payload.code) error.code = payload.code;
206
- if (payload.params) error.params = payload.params;
207
- if (payload.failed) error.failed = payload.failed;
208
- throw error;
209
- }
210
- return payload.data;
211
- });
201
+ return fetch("/api/dsh-skills-manager" + path, options).then(parseApiResponse);
202
+ }
203
+
204
+ function parseApiResponse(response) {
205
+ return response.json().catch(function () {
206
+ var error = new Error("HTTP " + response.status + " returned a non-JSON response");
207
+ error.code = "error.proto.nonJson";
208
+ error.params = { status: response.status };
209
+ throw error;
210
+ }).then(function (payload) {
211
+ if (!payload.ok) {
212
+ var error = new Error(payload.error || ("HTTP " + response.status));
213
+ if (payload.code) error.code = payload.code;
214
+ if (payload.params) error.params = payload.params;
215
+ if (payload.failed) error.failed = payload.failed;
216
+ throw error;
217
+ }
218
+ return payload.data;
212
219
  });
213
220
  }
214
221
 
215
- // 翻译业务错误:payload.code 存在则查词典(params 替换占位符),词典 miss 回退原文。
216
- // 兼容 { code, params, error }、Error 对象、纯字符串三种形态。
217
222
  function translateError(t, payload) {
223
+ // 调用方会传入 API payload、Error 和纯字符串,统一在此处兼容可避免漏翻译。
218
224
  if (payload && typeof payload === "object") {
219
225
  var code = payload.code;
220
226
  if (typeof code === "string" && code !== "") {
221
227
  var params = payload.params || {};
222
- // {action} 占位符按当前语言映射(enable/disable/delete → 启用/停用/删除)。
228
+ // error.* 使用动作键,而动作名称必须随当前语言切换。
223
229
  if (params.action !== undefined) {
224
230
  var actionKey = "action." + params.action;
225
231
  var actionText = t(actionKey);
226
232
  if (actionText !== undefined && actionText !== actionKey) params = Object.assign({}, params, { action: actionText });
227
233
  }
228
234
  var translated = t(code, params);
229
- // locale miss 时 t 返回 key 本身(或 undefined/null),此时回退原文。
230
235
  if (typeof translated === "string" && translated !== code) return translated;
231
236
  }
232
237
  if (payload.error !== undefined) return translateError(t, payload.error);
@@ -241,6 +246,25 @@ window.__ModuleLoader__.load({
241
246
  return typeof value === "string" && value !== key ? value : fallback;
242
247
  }
243
248
 
249
+ function isSkillEnabled(skill) {
250
+ return skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable;
251
+ }
252
+
253
+ function modalFocusable(modal) {
254
+ return Array.prototype.slice.call(modal.querySelectorAll("button:not(:disabled), [href], input:not([type=hidden]):not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex=\"-1\"])"));
255
+ }
256
+
257
+ function trapModalFocus(modal, event) {
258
+ if (event.key !== "Tab") return;
259
+ var focusable = modalFocusable(modal);
260
+ if (focusable.length === 0) return;
261
+ var active = document.activeElement;
262
+ if (!modal.contains(active) || (event.shiftKey ? active === focusable[0] : active === focusable[focusable.length - 1])) {
263
+ event.preventDefault();
264
+ (event.shiftKey ? focusable[focusable.length - 1] : focusable[0]).focus();
265
+ }
266
+ }
267
+
244
268
  // 渲染机制按 register 的 locale 字段把命名空间绑定的 t 注入组件 props。
245
269
  function SkillManagerSection(props) {
246
270
  var t = props.t;
@@ -266,6 +290,7 @@ window.__ModuleLoader__.load({
266
290
  var confirmDelete = deleteState[0];
267
291
  var setConfirmDelete = deleteState[1];
268
292
  var inputRef = react.useRef(null);
293
+ var modalRef = react.useRef(null);
269
294
 
270
295
  function refresh() {
271
296
  setSnapshot({ loading: true, error: null, data: snapshot.data });
@@ -293,6 +318,16 @@ window.__ModuleLoader__.load({
293
318
  return function () { window.removeEventListener("keydown", closeTopModal, true); };
294
319
  }, [uploadOpen, confirmImport, confirmDelete]);
295
320
 
321
+ react.useEffect(function () {
322
+ var modal = modalRef.current;
323
+ if (!modal) return undefined;
324
+ var focusable = modalFocusable(modal);
325
+ (focusable[0] || modal).focus();
326
+ function trapFocus(event) { trapModalFocus(modal, event); }
327
+ modal.addEventListener("keydown", trapFocus);
328
+ return function () { modal.removeEventListener("keydown", trapFocus); };
329
+ }, [uploadOpen, confirmImport, confirmDelete]);
330
+
296
331
  function action(path, body) {
297
332
  if (busy) return;
298
333
  setBusy(path);
@@ -375,9 +410,9 @@ window.__ModuleLoader__.load({
375
410
  }
376
411
 
377
412
  function skillRow(skill, root) {
378
- var enabled = skill.modelInvocable && skill.userInvocable;
379
- var policyValid = skill.invocationPolicyValid !== false;
380
- var actionEnabled = policyValid && enabled;
413
+ var policyValid = skill.invocationPolicyValid;
414
+ var enabled = isSkillEnabled(skill);
415
+ var actionEnabled = enabled;
381
416
  return h("div", { key: skill.name, className: "dssm-row" },
382
417
  h("div", { className: "dssm-row-main" },
383
418
  h("div", { className: "dssm-row-id" },
@@ -394,7 +429,7 @@ window.__ModuleLoader__.load({
394
429
  if (snapshot.data) {
395
430
  var roots = snapshot.data.roots || [];
396
431
  var skills = roots.reduce(function (all, root) { return all.concat(root.skills || []); }, []);
397
- var enabled = skills.filter(function (skill) { return skill.modelInvocable; }).length;
432
+ var enabled = skills.filter(isSkillEnabled).length;
398
433
  nodes.push(h("div", { key: "toolbar", className: "dssm-toolbar" },
399
434
  h("div", null, h("h2", { className: "dssm-title" }, t("title")), h("p", { className: "dssm-desc" }, t("desc"))),
400
435
  h("div", { className: "dssm-actions" },
@@ -438,7 +473,7 @@ window.__ModuleLoader__.load({
438
473
  nodes.push(h("div", { key: "import-result", className: isImportError || failedItems.length ? "dssm-error" : "dssm-note", role: "status" }, resultText));
439
474
  }
440
475
  if (uploadOpen) {
441
- nodes.push(h("div", { key: "upload", className: "dssm-mask" }, h("div", { className: "dssm-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "dssm-upload-title" },
476
+ nodes.push(h("div", { key: "upload", className: "dssm-mask" }, h("div", { ref: modalRef, tabIndex: -1, className: "dssm-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "dssm-upload-title" },
442
477
  h("div", { className: "dssm-modal-head" }, h("h3", { id: "dssm-upload-title", className: "dssm-modal-title" }, t("upload.title")), h("button", { className: "dssm-icon-btn", "aria-label": t("upload.close"), onClick: function () { setUploadOpen(false); } }, "×")),
443
478
  h("input", { ref: inputRef, className: "dssm-hidden-input", type: "file", accept: ".md", onChange: function (event) { selectFile(event.target.files && event.target.files[0]); event.target.value = ""; } }),
444
479
  h("div", { className: "dssm-dropzone", tabIndex: 0, onClick: openNativePicker, onKeyDown: function (event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openNativePicker(); } }, onDragOver: function (event) { event.preventDefault(); }, onDrop: function (event) { event.preventDefault(); selectFile(event.dataTransfer.files && event.dataTransfer.files[0]); } }, h("span", { className: "dssm-dropzone-title" }, t("upload.drop.title")), h("span", { className: "dssm-dropzone-copy" }, t("upload.drop.copy"))),
@@ -448,10 +483,10 @@ window.__ModuleLoader__.load({
448
483
  h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: openNativePicker }, t("btn.file.pick")), pickDirectory ? h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: selectDirectory }, t("btn.dir.pick")) : null, h("button", { className: "dssm-btn", disabled: !selected || !!busy, onClick: installSelected }, busy === "import" || busy === "import-check" ? t("btn.uploading") : t("btn.upload.dsh"))))));
449
484
  }
450
485
  if (confirmImport) {
451
- nodes.push(h("div", { key: "import-confirm", className: "dssm-mask" }, h("div", { className: "dssm-modal", role: "dialog", "aria-modal": "true" }, h("div", { className: "dssm-modal-head" }, h("h3", { className: "dssm-modal-title" }, t("confirm.overwrite.title"))), h("p", { className: "dssm-desc" }, t("confirm.overwrite.desc", { names: confirmImport.conflicts.map(function (item) { return item.name; }).join(t("sep.names")) })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", onClick: function () { setConfirmImport(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn", disabled: !!busy, onClick: function () { executeImport(confirmImport.source, "overwrite"); } }, t("btn.overwrite.upload"))))));
486
+ nodes.push(h("div", { key: "import-confirm", className: "dssm-mask" }, h("div", { ref: modalRef, tabIndex: -1, className: "dssm-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "dssm-import-title" }, h("div", { className: "dssm-modal-head" }, h("h3", { id: "dssm-import-title", className: "dssm-modal-title" }, t("confirm.overwrite.title"))), h("p", { className: "dssm-desc" }, t("confirm.overwrite.desc", { names: confirmImport.conflicts.map(function (item) { return item.name; }).join(t("sep.names")) })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", onClick: function () { setConfirmImport(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn", disabled: !!busy, onClick: function () { executeImport(confirmImport.source, "overwrite"); } }, t("btn.overwrite.upload"))))));
452
487
  }
453
488
  if (confirmDelete) {
454
- nodes.push(h("div", { key: "delete-confirm", className: "dssm-mask" }, h("div", { className: "dssm-modal", role: "dialog", "aria-modal": "true" }, h("div", { className: "dssm-modal-head" }, h("h3", { className: "dssm-modal-title" }, t("confirm.delete.title"))), h("p", { className: "dssm-desc" }, t("confirm.delete.desc", { name: confirmDelete.name })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: function () { setConfirmDelete(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn dssm-btn-danger", disabled: !!busy, onClick: function () { setConfirmDelete(null); action("/delete", { name: confirmDelete.name }); } }, t("btn.delete.confirm"))))));
489
+ nodes.push(h("div", { key: "delete-confirm", className: "dssm-mask" }, h("div", { ref: modalRef, tabIndex: -1, className: "dssm-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "dssm-delete-title" }, h("div", { className: "dssm-modal-head" }, h("h3", { id: "dssm-delete-title", className: "dssm-modal-title" }, t("confirm.delete.title"))), h("p", { className: "dssm-desc" }, t("confirm.delete.desc", { name: confirmDelete.name })), h("div", { className: "dssm-modal-actions" }, h("button", { className: "dssm-btn dssm-btn-secondary", disabled: !!busy, onClick: function () { setConfirmDelete(null); } }, t("btn.cancel")), h("button", { className: "dssm-btn dssm-btn-danger", disabled: !!busy, onClick: function () { setConfirmDelete(null); action("/delete", { name: confirmDelete.name }); } }, t("btn.delete.confirm"))))));
455
490
  }
456
491
  return h("section", { className: "dssm-section" }, nodes);
457
492
  }
@@ -470,6 +505,9 @@ window.__ModuleLoader__.load({
470
505
  // 导出词典与翻译函数供零依赖对齐测试读取(宿主只消费 apply/inject,不影响运行时)。
471
506
  module.exports.DICT = DICT;
472
507
  module.exports.translateError = translateError;
508
+ module.exports.isSkillEnabled = isSkillEnabled;
509
+ module.exports.parseApiResponse = parseApiResponse;
510
+ module.exports.trapModalFocus = trapModalFocus;
473
511
  return module.exports;
474
512
  }
475
513
  });
package/lib/core.js CHANGED
@@ -84,13 +84,17 @@ function pathsOverlap(a, b) {
84
84
  }
85
85
 
86
86
  /** 名称只允许一个普通路径段;不把既有技能名称限制为 kebab-case。 */
87
- function entryPath(root, name) {
88
- if (typeof name !== "string" || name === "" || name === "." || name === ".." || /[\\/\0]/.test(name) || /[. ]$/.test(name) || WINDOWS_DEVICE_NAME_RE.test(name) || basename(name) !== name) return null;
87
+ export function entryPath(root, name) {
88
+ if (typeof name !== "string" || name === "" || name === "." || name === ".." || /[\\/:*?"<>|\0]/.test(name) || /[. ]$/.test(name) || WINDOWS_DEVICE_NAME_RE.test(name) || basename(name) !== name) return null;
89
89
  const rootPath = resolve(root);
90
90
  const path = resolve(rootPath, name);
91
91
  return isSameOrDescendant(rootPath, path) && rootPath !== path ? path : null;
92
92
  }
93
93
 
94
+ function isDshRoot(root) {
95
+ return typeof root === "string" && resolve(root) === resolve(dshRootPath());
96
+ }
97
+
94
98
  // ── 命名规整 ────────────────────────────────────────────────────────────────
95
99
 
96
100
  /** 尽量把任意名称规整为 kebab-case;无法生成合法名称时返回空串。 */
@@ -259,13 +263,13 @@ export async function scanEntries(root) {
259
263
  const entries = [];
260
264
  for (const it of items) {
261
265
  try {
262
- if (it.isDirectory()) {
266
+ if (it.isDirectory() && entryPath(root, it.name) !== null) {
263
267
  const docPath = join(root, it.name, "SKILL.md");
264
268
  const st = await fs.stat(docPath);
265
269
  if (!st.isFile()) continue;
266
270
  const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
267
271
  entries.push(entryOf(it.name, "bundle", docPath, doc));
268
- } else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md") {
272
+ } else if (it.isFile() && it.name.toLowerCase().endsWith(".md") && it.name.toLowerCase() !== "skill.md" && entryPath(root, it.name.slice(0, -3)) !== null) {
269
273
  const docPath = join(root, it.name);
270
274
  const doc = parseSkillDoc(await fs.readFile(docPath, "utf8"));
271
275
  entries.push(entryOf(it.name.slice(0, -3), "flat", docPath, doc));
@@ -282,7 +286,7 @@ export async function scanEntries(root) {
282
286
 
283
287
  /** enabled=true 恢复模型与 / 手动调用;false 同时停用两种调用入口。 */
284
288
  export async function setSkillEnabled(root, name, enabled) {
285
- if (root !== dshRootPath()) return readonlyError("toggle");
289
+ if (!isDshRoot(root)) return readonlyError("toggle");
286
290
  const resolved = await resolveEntry(root, name);
287
291
  if (resolved === null) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
288
292
  const source = await fs.readFile(resolved.docPath, "utf8");
@@ -294,11 +298,19 @@ export async function setSkillEnabled(root, name, enabled) {
294
298
 
295
299
  /** 删除 DSH 根目录中的单个技能。调用方必须先向用户确认。 */
296
300
  export async function deleteSkill(root, name, log) {
297
- if (root !== dshRootPath()) return readonlyError("delete");
301
+ if (!isDshRoot(root)) return readonlyError("delete");
298
302
  const resolved = await resolveEntry(root, name);
299
303
  if (resolved === null) return { ok: false, error: `技能不存在: ${name}`, code: "error.skill.notFound", params: { name } };
300
- await fs.rm(resolved.entryPath, { recursive: resolved.kind === "bundle", force: true });
301
- if (log) log("delete", `删除 ${resolved.entryPath}`);
304
+ const bundlePath = entryPath(root, name);
305
+ const alternatePath = resolved.kind === "bundle" ? resolve(root, `${name}.md`) : bundlePath;
306
+ const alternateDocPath = resolved.kind === "bundle" ? alternatePath : join(alternatePath, "SKILL.md");
307
+ const targets = [{ path: resolved.entryPath, recursive: resolved.kind === "bundle" }];
308
+ try {
309
+ const st = await fs.stat(alternateDocPath);
310
+ if (st.isFile()) targets.push({ path: alternatePath, recursive: resolved.kind !== "bundle" });
311
+ } catch {}
312
+ for (const target of targets) await fs.rm(target.path, { recursive: target.recursive, force: true });
313
+ if (log) log("delete", `删除 ${targets.map((target) => target.path).join("、")}`);
302
314
  return { name };
303
315
  }
304
316
 
@@ -385,6 +397,7 @@ async function copyToTemporary(source, target, isDir) {
385
397
  await assertNoSymbolicLinks(source);
386
398
  if (isDir) await fs.cp(source, temp, { recursive: true, dereference: false });
387
399
  else await fs.copyFile(source, temp);
400
+ await assertNoSymbolicLinks(temp);
388
401
  return temp;
389
402
  } catch (error) {
390
403
  await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
@@ -405,7 +418,20 @@ async function replaceWithCopy(source, dest, isDir, existing = []) {
405
418
  await fs.rename(stage, dest);
406
419
  } catch (error) {
407
420
  await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
408
- for (const item of backups.reverse()) await fs.rename(item.backup, item.path).catch(() => undefined);
421
+ const rollbackFailures = [];
422
+ for (const item of backups.reverse()) {
423
+ try {
424
+ await fs.rename(item.backup, item.path);
425
+ } catch (rollbackError) {
426
+ rollbackFailures.push(`${item.backup}(${String(rollbackError && rollbackError.message ? rollbackError.message : rollbackError)})`);
427
+ }
428
+ }
429
+ if (rollbackFailures.length) {
430
+ const restoreError = new Error(`${String(error && error.message ? error.message : error)};覆盖导入回滚失败,备份保留在: ${rollbackFailures.join("、")}`);
431
+ if (error && error.code) restoreError.code = error.code;
432
+ if (error && error.params) restoreError.params = error.params;
433
+ throw restoreError;
434
+ }
409
435
  throw error;
410
436
  }
411
437
  const warnings = [];
@@ -453,7 +479,7 @@ export async function importSkill(source, log, options = {}) {
453
479
 
454
480
  const nameCount = new Map();
455
481
  for (const candidate of candidates) {
456
- if (candidate.kebab && KEBAB_RE.test(candidate.kebab)) nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
482
+ if (candidate.kebab && KEBAB_RE.test(candidate.kebab) && entryPath(targetRoot, candidate.kebab) !== null) nameCount.set(candidate.kebab, (nameCount.get(candidate.kebab) || 0) + 1);
457
483
  }
458
484
 
459
485
  function failureResult() {
@@ -470,7 +496,7 @@ export async function importSkill(source, log, options = {}) {
470
496
  }
471
497
 
472
498
  for (const c of candidates) {
473
- if (!c.kebab || !KEBAB_RE.test(c.kebab)) {
499
+ if (!c.kebab || !KEBAB_RE.test(c.kebab) || entryPath(targetRoot, c.kebab) === null) {
474
500
  failed.push({ source: c.source, error: `无法生成合法 kebab-case 名称(原始名: ${c.rawName || basename(c.source)})`, code: "error.import.invalidName", params: { name: c.rawName || basename(c.source) } });
475
501
  continue;
476
502
  }
@@ -494,6 +520,8 @@ export async function importSkill(source, log, options = {}) {
494
520
  pending.push({ name: c.kebab, source: c.source, isDir: c.isDir, dest });
495
521
  }
496
522
 
523
+ if (pending.length === 0 && conflicts.length === 0) return failureResult();
524
+
497
525
  if (dryRun) {
498
526
  if (failed.length && pending.length === 0 && conflicts.length === 0) return failureResult();
499
527
  return { kind: analysis.kind, pending, conflicts, failed };
package/lib/index.js CHANGED
@@ -74,6 +74,8 @@ function readBody(req, limit = 1 << 20) {
74
74
 
75
75
  /** 自定义请求头使跨站 fetch 必须预检;本地接口不提供 CORS 响应。 */
76
76
  function validateMutationRequest(req) {
77
+ const host = String(req.headers.host || "").toLowerCase();
78
+ if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host)) return { statusCode: 403, code: "error.proto.forbidden", error: "forbidden host" };
77
79
  if (req.headers[CLIENT_MARKER_HEADER] !== "1") return { statusCode: 403, code: "error.proto.forbidden", error: "forbidden mutation request" };
78
80
  const contentType = String(req.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
79
81
  if (contentType !== "application/json") return { statusCode: 415, code: "error.proto.contentType", error: "content-type must be application/json" };
@@ -90,8 +92,8 @@ function json(res, code, payload) {
90
92
  }
91
93
 
92
94
  /** 成功统一包成 { ok: true, data };核心返回 { ok:false, error } 时透传为 400。 */
93
- function run(res, p, afterSuccess) {
94
- p.then((r) => {
95
+ function run(res, task, afterSuccess) {
96
+ return Promise.resolve().then(task).then((r) => {
95
97
  if (r && r.ok === false) json(res, 400, r);
96
98
  else {
97
99
  try {
@@ -103,7 +105,11 @@ function run(res, p, afterSuccess) {
103
105
  }
104
106
  }).catch((e) => {
105
107
  try {
106
- json(res, 500, { ok: false, error: String(e && e.message ? e.message : e) });
108
+ json(res, Number.isInteger(e && e.statusCode) ? e.statusCode : 500, {
109
+ ok: false,
110
+ ...(e && e.code ? { code: e.code } : {}),
111
+ error: String(e && e.message ? e.message : e),
112
+ });
107
113
  } catch {
108
114
  /* response already closed */
109
115
  }
@@ -115,6 +121,12 @@ function apply(ctx) {
115
121
  const roots = userRoots();
116
122
  const rootByKey = Object.fromEntries(roots.map((r) => [r.key, r.path]));
117
123
  let invalidateSkills = () => {};
124
+ let mutationQueue = Promise.resolve();
125
+ const enqueueMutation = (task) => {
126
+ const queued = mutationQueue.then(task, task);
127
+ mutationQueue = queued.catch(() => undefined);
128
+ return queued;
129
+ };
118
130
  ctx.effect(() => ctx.skills.registerProvider((control) => {
119
131
  invalidateSkills = control.invalidate;
120
132
  return {
@@ -132,8 +144,7 @@ function apply(ctx) {
132
144
  const path = u.pathname.replace(/\/+$/, "");
133
145
  try {
134
146
  if (req.method === "GET" && path === "/api/dsh-skills-manager/state") {
135
- run(res, state());
136
- return;
147
+ return run(res, state);
137
148
  }
138
149
  if (req.method !== "POST") {
139
150
  json(res, 405, { ok: false, code: "error.proto.method", error: `method not allowed: ${req.method}` });
@@ -141,32 +152,27 @@ function apply(ctx) {
141
152
  }
142
153
  const requestError = validateMutationRequest(req);
143
154
  if (requestError) {
144
- json(res, requestError.statusCode, { ok: false, error: requestError.error });
155
+ json(res, requestError.statusCode, { ok: false, code: requestError.code, error: requestError.error });
145
156
  return;
146
157
  }
147
158
  const body = await readBody(req);
148
- switch (path) {
149
- case "/api/dsh-skills-manager/enable": {
150
- run(res, setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), true), invalidateSkills);
151
- return;
152
- }
153
- case "/api/dsh-skills-manager/disable": {
154
- run(res, setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), false), invalidateSkills);
155
- return;
159
+ return enqueueMutation(() => {
160
+ switch (path) {
161
+ case "/api/dsh-skills-manager/enable":
162
+ return run(res, () => setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), true), invalidateSkills);
163
+ case "/api/dsh-skills-manager/disable":
164
+ return run(res, () => setSkillEnabled(rootByKey[String(body.root || "dsh")], String(body.name || ""), false), invalidateSkills);
165
+ case "/api/dsh-skills-manager/delete":
166
+ return run(res, () => deleteSkill(rootByKey.dsh, String(body.name || ""), log), invalidateSkills);
167
+ case "/api/dsh-skills-manager/import":
168
+ return run(res, () => importSkill(String(body.source || ""), log, {
169
+ conflict: body.conflict === "overwrite" ? "overwrite" : "skip",
170
+ dryRun: body.dryRun === true,
171
+ }), body.dryRun === true ? undefined : invalidateSkills);
172
+ default:
173
+ json(res, 404, { ok: false, code: "error.proto.unknownAction", error: `unknown action: ${path}` });
156
174
  }
157
- case "/api/dsh-skills-manager/delete": {
158
- run(res, deleteSkill(rootByKey.dsh, String(body.name || ""), log), invalidateSkills);
159
- return;
160
- }
161
- case "/api/dsh-skills-manager/import":
162
- run(res, importSkill(String(body.source || ""), log, {
163
- conflict: body.conflict === "overwrite" ? "overwrite" : "skip",
164
- dryRun: body.dryRun === true,
165
- }), body.dryRun === true ? undefined : invalidateSkills);
166
- return;
167
- default:
168
- json(res, 404, { ok: false, code: "error.proto.unknownAction", error: `unknown action: ${path}` });
169
- }
175
+ });
170
176
  } catch (e) {
171
177
  json(res, Number.isInteger(e && e.statusCode) ? e.statusCode : 500, {
172
178
  ok: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@michengai/dsh-skills-manager",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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": {