dsh-plugin-tool-management 0.16.6 → 0.18.0
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/CHANGELOG.md +115 -0
- package/README.md +20 -4
- package/README_EN.md +20 -4
- package/docs/images/1-EN.png +0 -0
- package/docs/images/1.png +0 -0
- package/docs/images/2-EN.png +0 -0
- package/docs/images/2.png +0 -0
- package/docs/images/3-EN.png +0 -0
- package/docs/images/3.png +0 -0
- package/docs/images/4-EN.png +0 -0
- package/docs/images/4.png +0 -0
- package/docs/images/5-EN.png +0 -0
- package/docs/images/5.png +0 -0
- package/docs/images/6-EN.png +0 -0
- package/docs/images/6.png +0 -0
- package/docs/images/7-EN.png +0 -0
- package/docs/images/7.png +0 -0
- package/docs/images/8-EN.png +0 -0
- package/docs/images/8.png +0 -0
- package/docs/update.md +59 -0
- package/lib/audit-log.js +6 -2
- package/lib/client.js +1615 -148
- package/lib/compat/capability-specs.js +321 -0
- package/lib/compat/host-probe.js +261 -0
- package/lib/compat/host-roots.js +168 -0
- package/lib/compat/probe-types.js +6 -0
- package/lib/compat/probe.js +54 -714
- package/lib/context-inject-contract.js +114 -0
- package/lib/context-inject-frame.js +147 -0
- package/lib/context-inject-select.js +127 -0
- package/lib/context-inject-surface.js +97 -0
- package/lib/context-inject.js +57 -459
- package/lib/host-names.js +150 -0
- package/lib/http-api.js +229 -0
- package/lib/hub.js +84 -11
- package/lib/imports/parsers.js +40 -15
- package/lib/index.js +235 -843
- package/lib/mcp/inspect-probe.js +199 -0
- package/lib/mcp/loader-token.js +26 -0
- package/lib/mcp/manager.js +267 -558
- package/lib/mcp/patch-yaml.js +207 -40
- package/lib/mcp/secret-guard.js +122 -16
- package/lib/mcp/tool-visibility.js +388 -0
- package/lib/memories/archive-engine.js +163 -16
- package/lib/memories/archive.js +10 -1
- package/lib/memories/discovery.js +261 -0
- package/lib/memories/export-plan.js +42 -0
- package/lib/memories/index-io.js +125 -12
- package/lib/memories/memories-types.js +1 -0
- package/lib/memories/service.js +167 -78
- package/lib/memories/snapshot.js +41 -260
- package/lib/op-registry.js +72 -8
- package/lib/ops/compat.js +17 -2
- package/lib/ops/prompts.js +18 -5
- package/lib/ops/quick-prompts.js +26 -0
- package/lib/ops/scene-records.js +157 -10
- package/lib/ops/scene-sync.js +71 -1
- package/lib/ops/sessions.js +63 -13
- package/lib/ops/snapshot.js +123 -24
- package/lib/ops/trash.js +45 -9
- package/lib/prompts/quick-service.js +211 -0
- package/lib/prompts/service.js +7 -2
- package/lib/request-gate.js +115 -56
- package/lib/scene-linkage.js +184 -0
- package/lib/scene-prompt-sync.js +46 -7
- package/lib/scenes/candidates.js +21 -3
- package/lib/sessions/archive-schemas.js +121 -0
- package/lib/sessions/path-key.js +46 -0
- package/lib/sessions/workspace.js +45 -168
- package/lib/settings-sidecars.js +143 -0
- package/lib/skills/core-types.js +8 -0
- package/lib/skills/core-util.js +81 -0
- package/lib/skills/core.js +97 -2297
- package/lib/skills/dir-watch.js +71 -0
- package/lib/skills/frontmatter.js +96 -0
- package/lib/skills/homes.js +14 -0
- package/lib/skills/import-pipeline.js +231 -0
- package/lib/skills/manager-state.js +800 -0
- package/lib/skills/official.js +269 -0
- package/lib/skills/provider-registration.js +89 -0
- package/lib/skills/service.js +4 -234
- package/lib/skills/skill-roots.js +287 -0
- package/lib/skills/skill-trash.js +265 -0
- package/lib/skills/system-trash.js +97 -0
- package/lib/skills/upload-pipeline.js +374 -0
- package/lib/slash-settings.js +25 -0
- package/lib/subagents/persona-parse.js +312 -0
- package/lib/subagents/persona-types.js +5 -0
- package/lib/subagents/persona-write.js +93 -0
- package/lib/subagents/run-provider.js +159 -0
- package/lib/subagents/service.js +20 -639
- package/lib/subagents/tool-filter.js +98 -0
- package/lib/token-config.js +240 -0
- package/lib/tool-table-switch.js +163 -0
- package/lib/tools/scene.js +40 -9
- package/package.json +3 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// 目录监听:外部改动 200ms 防抖后通知失效(技能来源与规则根目录共用同一份实现)。
|
|
2
|
+
//
|
|
3
|
+
// 从 skills/service.ts 整段搬来,一行未改。为什么走 worker_threads 而不是 fs.watch(recursive)
|
|
4
|
+
// 写在它自己的注释里 —— Windows 上那条路会在目录被删除时静默卡死事件循环。
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { Worker } from 'node:worker_threads';
|
|
7
|
+
// ── 文件监听:外部改动自动失效 ────────────────────────────────────────────
|
|
8
|
+
/**
|
|
9
|
+
* 监听目录,200ms 防抖后回调:在编辑器或其他工具里新增/修改/删除文件后,无需手动
|
|
10
|
+
* 刷新即可看到变化。技能来源与规则根目录共用(见 src/memories/service.ts 的场景记忆段缓存)。
|
|
11
|
+
*
|
|
12
|
+
* 为什么用 worker_threads:Windows 上 fs.watch(recursive) 的句柄在「被监听
|
|
13
|
+
* 目录被删除」时会静默卡死事件循环(不触发 error、unref 也无效)——宿主
|
|
14
|
+
* 进程将永远无法退出。把 watcher 放进独立的 worker 线程,主线程对 worker
|
|
15
|
+
* unref(),无论目录发生什么,宿主与测试进程都能正常收尾;worker 内部失败
|
|
16
|
+
* 也不影响主线程。目录不存在或平台不支持递归监听时静默跳过。
|
|
17
|
+
*/
|
|
18
|
+
export function watchDirectories(paths, invalidate) {
|
|
19
|
+
const valid = paths.filter((dir) => dir && existsSync(dir));
|
|
20
|
+
if (!valid.length)
|
|
21
|
+
return () => { };
|
|
22
|
+
let timer = null;
|
|
23
|
+
const fire = () => {
|
|
24
|
+
if (timer)
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
timer = setTimeout(() => {
|
|
27
|
+
timer = null;
|
|
28
|
+
try {
|
|
29
|
+
invalidate();
|
|
30
|
+
}
|
|
31
|
+
catch { /* 失效失败不影响监听 */ }
|
|
32
|
+
}, 200);
|
|
33
|
+
};
|
|
34
|
+
let worker = null;
|
|
35
|
+
try {
|
|
36
|
+
// eval 模式内联 worker 代码:无需额外文件与构建步骤。worker 事件循环独立,
|
|
37
|
+
// 主线程 unref 后完全不参与宿主进程的退出判定。
|
|
38
|
+
const src = `
|
|
39
|
+
const { watch } = require('node:fs');
|
|
40
|
+
const { parentPort, workerData } = require('node:worker_threads');
|
|
41
|
+
for (const dir of workerData.paths) {
|
|
42
|
+
try {
|
|
43
|
+
const w = watch(dir, { recursive: true, persistent: false }, () => {
|
|
44
|
+
try { parentPort.postMessage('change') } catch { /* worker 正在关闭 */ }
|
|
45
|
+
});
|
|
46
|
+
w.on('error', () => { /* 目录被移除等:忽略,不向主线程传播 */ });
|
|
47
|
+
} catch { /* 不可监听(权限/平台)时跳过该目录 */ }
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
50
|
+
worker = new Worker(src, { eval: true, workerData: { paths: valid } });
|
|
51
|
+
worker.unref();
|
|
52
|
+
worker.on('message', () => fire());
|
|
53
|
+
worker.on('error', () => { });
|
|
54
|
+
}
|
|
55
|
+
catch { /* worker 不可用时静默跳过,功能退化为依赖 op 全量扫描 */ }
|
|
56
|
+
return () => {
|
|
57
|
+
if (timer) {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
timer = null;
|
|
60
|
+
}
|
|
61
|
+
if (worker) {
|
|
62
|
+
const w = worker;
|
|
63
|
+
worker = null;
|
|
64
|
+
try {
|
|
65
|
+
w.removeAllListeners();
|
|
66
|
+
w.terminate();
|
|
67
|
+
}
|
|
68
|
+
catch { /* ignore */ }
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// ── 命名规整 ────────────────────────────────────────────────────────────────
|
|
2
|
+
/** 尽量把任意名称规整为 kebab-case;无法生成合法名称时返回空串。 */
|
|
3
|
+
export function toKebab(s) {
|
|
4
|
+
let t = String(s).trim();
|
|
5
|
+
if (t === "")
|
|
6
|
+
return "";
|
|
7
|
+
t = t.replace(/([a-z0-9])([A-Z])/g, "$1-$2"); // camelCase 边界
|
|
8
|
+
t = t.toLowerCase();
|
|
9
|
+
t = t.replace(/[\s_.]+/g, "-");
|
|
10
|
+
t = t.replace(/[^a-z0-9-]/g, "-");
|
|
11
|
+
t = t.replace(/-+/g, "-").replace(/^-+|-+$/g, "");
|
|
12
|
+
return t;
|
|
13
|
+
}
|
|
14
|
+
// ── frontmatter 解析 / 序列化(宽松 YAML 对象,保留键序)────────────────────
|
|
15
|
+
/** 剥离 UTF-8 BOM(Windows 工具常写入,不剥离会导致开头 --- 失配)。 */
|
|
16
|
+
export function stripBom(text) {
|
|
17
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
18
|
+
}
|
|
19
|
+
/** 解析 SKILL.md 的 frontmatter。返回 { fields, map, body },map 保留键序。
|
|
20
|
+
* 只识别顶层 key: value;缩进嵌套字段(如 metadata.source)不进入 map。 */
|
|
21
|
+
export function parseSkillDoc(text) {
|
|
22
|
+
const src = stripBom(String(text));
|
|
23
|
+
const lines = src.split(/\r?\n/);
|
|
24
|
+
const map = Object.create(null);
|
|
25
|
+
const fields = [];
|
|
26
|
+
let body = src;
|
|
27
|
+
let hasFrontmatter = false;
|
|
28
|
+
if (lines.length > 0 && lines[0].trim() === "---") {
|
|
29
|
+
let end = -1;
|
|
30
|
+
for (let i = 1; i < lines.length; i++) {
|
|
31
|
+
if (lines[i].trim() === "---") {
|
|
32
|
+
end = i;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (end >= 0) {
|
|
37
|
+
hasFrontmatter = true;
|
|
38
|
+
for (let i = 1; i < end; i++) {
|
|
39
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(lines[i]);
|
|
40
|
+
if (m) {
|
|
41
|
+
fields.push({ key: m[1], raw: m[2] });
|
|
42
|
+
const block = /^([>|])[-+]?\s*$/.exec(m[2]);
|
|
43
|
+
if (block) {
|
|
44
|
+
const content = [];
|
|
45
|
+
while (i + 1 < end &&
|
|
46
|
+
(/^\s/.test(lines[i + 1]) || lines[i + 1] === "")) {
|
|
47
|
+
i++;
|
|
48
|
+
content.push(lines[i]);
|
|
49
|
+
}
|
|
50
|
+
const indentation = content
|
|
51
|
+
.filter((line) => line.trim() !== "")
|
|
52
|
+
.reduce((min, line) => Math.min(min, (/^\s*/.exec(line) || [""])[0].length), Infinity);
|
|
53
|
+
const normalized = content.map((line) => Number.isFinite(indentation)
|
|
54
|
+
? line.slice(Math.min(indentation, line.length))
|
|
55
|
+
: line);
|
|
56
|
+
map[m[1]] =
|
|
57
|
+
block[1] === ">"
|
|
58
|
+
? normalized.join(" ").replace(/\s+/g, " ").trim()
|
|
59
|
+
: normalized.join("\n").trim();
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
map[m[1]] = decodeYamlScalar(m[2]);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
body = lines.slice(end + 1).join("\n");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { fields, map, body, hasFrontmatter };
|
|
70
|
+
}
|
|
71
|
+
/** 读取 YAML 标量的显示值;不依赖第三方 YAML 解析器。 */
|
|
72
|
+
export function decodeYamlScalar(v) {
|
|
73
|
+
const s = String(v == null ? "" : v).trim();
|
|
74
|
+
if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
|
|
75
|
+
try {
|
|
76
|
+
const value = JSON.parse(s);
|
|
77
|
+
if (typeof value === "string")
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* 保留无法解析的原始内容 */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (s.length >= 2 && s[0] === "'" && s[s.length - 1] === "'")
|
|
85
|
+
return s.slice(1, -1).replace(/''/g, "'");
|
|
86
|
+
return s;
|
|
87
|
+
}
|
|
88
|
+
export function unquote(v) {
|
|
89
|
+
const s = String(v == null ? "" : v).trim();
|
|
90
|
+
if (s.length >= 2 &&
|
|
91
|
+
((s[0] === '"' && s[s.length - 1] === '"') ||
|
|
92
|
+
(s[0] === "'" && s[s.length - 1] === "'"))) {
|
|
93
|
+
return s.slice(1, -1);
|
|
94
|
+
}
|
|
95
|
+
return s;
|
|
96
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// $DSH_HOME / ~/.agents 两个用户级 home 的解析。
|
|
2
|
+
//
|
|
3
|
+
// 单独一层是为了断环:core.ts 与 official.ts 都要读它,让它留在 core.ts 里就等于
|
|
4
|
+
// official.ts 反向 import core.js。同目录已有先例(memories/constants.ts 也是为断环抽出)。
|
|
5
|
+
// 两个函数的口径照旧读环境变量,没有引入任何新解析。
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
// ── 路径解析 ────────────────────────────────────────────────────────────────
|
|
9
|
+
export function resolveDshHome() {
|
|
10
|
+
return process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
11
|
+
}
|
|
12
|
+
export function resolveAgentsHome() {
|
|
13
|
+
return process.env.DSH_AGENTS_HOME || join(homedir(), ".agents");
|
|
14
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// 导入预检链路:把来源目录读成候选清单、拒符号链接、拷进同目录临时件、原子替换目标。
|
|
2
|
+
//
|
|
3
|
+
// 声明从 skills/core.ts 按行号区间搬来,正文一字未改(唯一的容差是 `--export-top-level`
|
|
4
|
+
// 加的 `export ` 前缀,moved-verify 逐字对拍)。
|
|
5
|
+
//
|
|
6
|
+
// 为什么这一层要独立:它是「导入」这条链的下半段,向下只引 ./core-util.js 与 node:* /
|
|
7
|
+
// ../paths.js,**不回引 core.js** —— 所以 core.js 反过来引它是单向的,加载图无环。
|
|
8
|
+
// 留在 core.ts 里时,任何想把上传/导入段搬出去的尝试都会撞到这个环上(0.18.0 试过一回,
|
|
9
|
+
// 判给不做就是这个原因)。
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
import { promises as fs } from "node:fs";
|
|
12
|
+
import { attachCode, codedError, lstatOrNull, renameWithRetry, temporaryPath } from "./core-util.js";
|
|
13
|
+
import { toKebab } from "./frontmatter.js";
|
|
14
|
+
export const MAX_SOURCE_DEPTH = 64;
|
|
15
|
+
export async function analyzeSource(source) {
|
|
16
|
+
let st;
|
|
17
|
+
try {
|
|
18
|
+
st = await fs.lstat(source);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {
|
|
22
|
+
kind: "none",
|
|
23
|
+
error: `路径不存在: ${source}`,
|
|
24
|
+
code: "error.source.notFound",
|
|
25
|
+
params: { path: source },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (st.isSymbolicLink())
|
|
29
|
+
return {
|
|
30
|
+
kind: "none",
|
|
31
|
+
error: `不支持包含符号链接的 skill 来源: ${source}`,
|
|
32
|
+
code: "error.source.symlink",
|
|
33
|
+
params: { path: source },
|
|
34
|
+
};
|
|
35
|
+
if (st.isDirectory()) {
|
|
36
|
+
const sk = join(source, "SKILL.md");
|
|
37
|
+
const skSt = await lstatOrNull(sk);
|
|
38
|
+
// 预检与实际导入口径一致:SKILL.md 本身是链接时直接拒绝,避免 dry-run 通过、正式导入才失败。
|
|
39
|
+
if (skSt && skSt.isSymbolicLink())
|
|
40
|
+
return {
|
|
41
|
+
kind: "none",
|
|
42
|
+
error: `不支持包含符号链接的 skill 来源: ${sk}`,
|
|
43
|
+
code: "error.source.symlink",
|
|
44
|
+
params: { path: sk },
|
|
45
|
+
};
|
|
46
|
+
if (skSt && skSt.isFile()) {
|
|
47
|
+
return {
|
|
48
|
+
kind: "single",
|
|
49
|
+
rawName: basename(source),
|
|
50
|
+
kebab: toKebab(basename(source)),
|
|
51
|
+
source,
|
|
52
|
+
isDir: true,
|
|
53
|
+
skillFile: sk,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { kind: "batch", rawName: basename(source), source, isDir: true };
|
|
57
|
+
}
|
|
58
|
+
if (st.isFile() && source.toLowerCase().endsWith(".md")) {
|
|
59
|
+
if (basename(source).toLowerCase() === "skill.md") {
|
|
60
|
+
const parent = dirname(source);
|
|
61
|
+
return {
|
|
62
|
+
kind: "single",
|
|
63
|
+
rawName: basename(parent),
|
|
64
|
+
kebab: toKebab(basename(parent)),
|
|
65
|
+
source: parent,
|
|
66
|
+
isDir: true,
|
|
67
|
+
skillFile: source,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const rawName = basename(source).slice(0, -3);
|
|
71
|
+
return {
|
|
72
|
+
kind: "single",
|
|
73
|
+
rawName,
|
|
74
|
+
kebab: toKebab(rawName),
|
|
75
|
+
source,
|
|
76
|
+
isDir: false,
|
|
77
|
+
skillFile: source,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
kind: "none",
|
|
82
|
+
error: `无法识别的 skill 来源: ${source}`,
|
|
83
|
+
code: "error.source.unrecognized",
|
|
84
|
+
params: { path: source },
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export async function collectCandidates(dir) {
|
|
88
|
+
const items = await fs.readdir(dir, { withFileTypes: true });
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const it of items) {
|
|
91
|
+
if (it.isSymbolicLink())
|
|
92
|
+
throw codedError(`不支持包含符号链接的 skill 来源: ${join(dir, it.name)}`, "error.source.symlink", { path: join(dir, it.name) });
|
|
93
|
+
if (it.isDirectory()) {
|
|
94
|
+
const sk = join(dir, it.name, "SKILL.md");
|
|
95
|
+
// lstatOrNull 吞掉 IO 异常(返回 null 即跳过);symlink 必须抛出,不能被“跳过”逻辑掩盖。
|
|
96
|
+
const st = await lstatOrNull(sk);
|
|
97
|
+
if (st && st.isSymbolicLink())
|
|
98
|
+
throw codedError(`不支持包含符号链接的 skill 来源: ${sk}`, "error.source.symlink", { path: sk });
|
|
99
|
+
if (st && st.isFile()) {
|
|
100
|
+
out.push({
|
|
101
|
+
source: join(dir, it.name),
|
|
102
|
+
kebab: toKebab(it.name),
|
|
103
|
+
rawName: it.name,
|
|
104
|
+
isDir: true,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
else if (it.isFile() &&
|
|
109
|
+
it.name.toLowerCase().endsWith(".md") &&
|
|
110
|
+
it.name.toLowerCase() !== "skill.md") {
|
|
111
|
+
out.push({
|
|
112
|
+
source: join(dir, it.name),
|
|
113
|
+
kebab: toKebab(it.name.slice(0, -3)),
|
|
114
|
+
rawName: it.name.slice(0, -3),
|
|
115
|
+
isDir: false,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
export async function assertNoSymbolicLinks(source) {
|
|
122
|
+
const pending = [
|
|
123
|
+
{ path: source, depth: 0 },
|
|
124
|
+
];
|
|
125
|
+
while (pending.length) {
|
|
126
|
+
// while 条件保证栈非空,断言只为让类型收敛。
|
|
127
|
+
const current = pending.pop();
|
|
128
|
+
if (current.depth > MAX_SOURCE_DEPTH)
|
|
129
|
+
throw codedError(`skill 来源目录层级超过 ${MAX_SOURCE_DEPTH} 层: ${source}`, "error.source.tooDeep", { depth: MAX_SOURCE_DEPTH, path: source });
|
|
130
|
+
const st = await fs.lstat(current.path);
|
|
131
|
+
if (st.isSymbolicLink())
|
|
132
|
+
throw codedError(`不支持包含符号链接的 skill 来源: ${current.path}`, "error.source.symlink", { path: current.path });
|
|
133
|
+
if (!st.isDirectory())
|
|
134
|
+
continue;
|
|
135
|
+
const items = await fs.readdir(current.path, { withFileTypes: true });
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
const path = join(current.path, item.name);
|
|
138
|
+
if (item.isSymbolicLink())
|
|
139
|
+
throw codedError(`不支持包含符号链接的 skill 来源: ${path}`, "error.source.symlink", { path });
|
|
140
|
+
if (item.isDirectory())
|
|
141
|
+
pending.push({ path, depth: current.depth + 1 });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
export async function preflightCandidates(pending, conflicts, failed) {
|
|
146
|
+
for (const group of [pending, conflicts]) {
|
|
147
|
+
for (let i = group.length - 1; i >= 0; i--) {
|
|
148
|
+
const candidate = group[i];
|
|
149
|
+
try {
|
|
150
|
+
await assertNoSymbolicLinks(candidate.source);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
// 系统异常按可选 message 读取,保持既有失败明细文案。
|
|
154
|
+
failed.push(attachCode({
|
|
155
|
+
source: candidate.source,
|
|
156
|
+
error: String(error && error.message ? error.message : error),
|
|
157
|
+
}, error));
|
|
158
|
+
group.splice(i, 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export async function copyToTemporary(source, target, isDir) {
|
|
164
|
+
const temp = temporaryPath(target, "stage");
|
|
165
|
+
try {
|
|
166
|
+
await assertNoSymbolicLinks(source);
|
|
167
|
+
if (isDir)
|
|
168
|
+
await fs.cp(source, temp, { recursive: true, dereference: false });
|
|
169
|
+
else
|
|
170
|
+
await fs.copyFile(source, temp);
|
|
171
|
+
await assertNoSymbolicLinks(temp);
|
|
172
|
+
return temp;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
await fs.rm(temp, { recursive: true, force: true }).catch(() => undefined);
|
|
176
|
+
throw error;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export async function replaceWithCopy(source, dest, isDir, existing = []) {
|
|
180
|
+
const stage = await copyToTemporary(source, dest, isDir);
|
|
181
|
+
const backups = [];
|
|
182
|
+
// 三处 rename 一律走 `renameWithRetry`:瞬时占用(Windows 杀软 / 索引器 / 资源管理器)返回的
|
|
183
|
+
// EACCES / EBUSY / EPERM 正是它重试的那些码。此前只有 `:959` 的回滚用了带重试的版本,
|
|
184
|
+
// 而**发布与它的补偿回滚用的是裸 rename** —— 发布因瞬时锁抛错时,紧随其后的回滚在同一刻、
|
|
185
|
+
// 同一目录上大概率撞同一个句柄,于是留下"目标已空、真值还叫 backup"的半态(无自动恢复路径)。
|
|
186
|
+
// 保护覆盖不均不是取舍,是漏。
|
|
187
|
+
try {
|
|
188
|
+
for (const path of existing) {
|
|
189
|
+
const backup = temporaryPath(path, "backup");
|
|
190
|
+
await renameWithRetry(path, backup);
|
|
191
|
+
backups.push({ path, backup });
|
|
192
|
+
}
|
|
193
|
+
await renameWithRetry(stage, dest);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
// 系统异常按可选 message 读取,保持既有回滚失败明细。
|
|
197
|
+
await fs.rm(stage, { recursive: true, force: true }).catch(() => undefined);
|
|
198
|
+
const rollbackFailures = [];
|
|
199
|
+
for (const item of backups.reverse()) {
|
|
200
|
+
try {
|
|
201
|
+
await renameWithRetry(item.backup, item.path);
|
|
202
|
+
}
|
|
203
|
+
catch (rollbackError) {
|
|
204
|
+
rollbackFailures.push(item.backup);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (rollbackFailures.length) {
|
|
208
|
+
const causeText = String(error && error.message ? error.message : error);
|
|
209
|
+
throw codedError(`${causeText};覆盖导入回滚失败,备份保留在: ${rollbackFailures.join("、")}`, "error.import.rollbackFailed", { path: rollbackFailures.join("; "), error: causeText });
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
const warnings = [];
|
|
214
|
+
for (const item of backups) {
|
|
215
|
+
try {
|
|
216
|
+
await fs.rm(item.backup, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
// 系统异常按可选 message 读取,保持既有告警文案。
|
|
220
|
+
warnings.push({
|
|
221
|
+
code: "warning.backupUncleaned",
|
|
222
|
+
params: {
|
|
223
|
+
path: item.backup,
|
|
224
|
+
error: String(error && error.message ? error.message : error),
|
|
225
|
+
},
|
|
226
|
+
error: `旧版本备份未清理: ${item.backup}(${String(error && error.message ? error.message : error)})`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return warnings;
|
|
231
|
+
}
|