@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 +64 -26
- package/lib/core.js +39 -11
- package/lib/index.js +33 -27
- package/package.json +1 -1
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
|
-
//
|
|
12
|
+
// 词典命名空间必须与 settings.section 的 locale 一致,宿主据此注入 t。
|
|
13
13
|
var NS = "skills-manager";
|
|
14
|
-
//
|
|
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(
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
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
|
|
379
|
-
var
|
|
380
|
-
var actionEnabled =
|
|
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(
|
|
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 === ".." || /[
|
|
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
|
|
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
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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,
|
|
94
|
-
|
|
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,
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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