@spzhongwin/skill-logger-plugin 1.0.14 → 1.0.16
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/dist/index.js +80 -285
- package/openclaw.plugin.json +50 -50
- package/package.json +34 -37
- package/src/active-skills.test.ts +32 -32
- package/src/active-skills.ts +77 -77
- package/src/config-sync.test.ts +165 -165
- package/src/config-sync.ts +544 -544
- package/src/expert-skill-layout.test.ts +196 -0
- package/src/expert-skill-layout.ts +233 -0
- package/src/global.d.ts +4 -0
- package/src/hooks.test.ts +228 -251
- package/src/hooks.ts +494 -517
- package/src/http.ts +61 -61
- package/src/identity.ts +88 -64
- package/src/index.test.ts +53 -53
- package/src/index.ts +218 -226
- package/src/integration.test.ts +119 -119
- package/src/matcher.test.ts +170 -170
- package/src/matcher.ts +393 -393
- package/src/paths.test.ts +57 -57
- package/src/paths.ts +84 -84
- package/src/reporter.test.ts +139 -139
- package/src/reporter.ts +303 -298
- package/src/sample-config.json +72 -72
- package/src/semver.test.ts +33 -23
- package/src/semver.ts +65 -60
- package/src/skill-version.ts +53 -53
- package/src/types.ts +202 -198
- package/src/updater.test.ts +431 -325
- package/src/updater.ts +584 -549
- package/src/ws-client.test.ts +158 -128
- package/src/ws-client.ts +805 -769
- package/test-ws.ts +17 -17
- package/tsconfig.json +18 -14
- package/dist/active-skills.js +0 -67
- package/dist/active-skills.test.js +0 -29
- package/dist/config-sync.js +0 -439
- package/dist/config-sync.test.js +0 -145
- package/dist/hooks.js +0 -337
- package/dist/hooks.test.js +0 -123
- package/dist/http.js +0 -54
- package/dist/identity.js +0 -56
- package/dist/index.test.js +0 -39
- package/dist/integration.test.js +0 -102
- package/dist/matcher.js +0 -362
- package/dist/matcher.test.js +0 -139
- package/dist/paths.js +0 -62
- package/dist/paths.test.js +0 -49
- package/dist/reporter.js +0 -267
- package/dist/reporter.test.js +0 -128
- package/dist/semver.js +0 -64
- package/dist/semver.test.js +0 -21
- package/dist/skill-version.js +0 -23
- package/dist/types.js +0 -9
- package/dist/updater.js +0 -352
- package/dist/updater.test.js +0 -212
- package/dist/ws-client.js +0 -484
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { repairNestedExpertSkillLayouts } from "./expert-skill-layout.ts";
|
|
7
|
+
|
|
8
|
+
let root: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), "expert-skill-layout-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function writeSkill(dir: string, version: string, marker: string) {
|
|
19
|
+
await fs.mkdir(path.join(dir, "scripts"), { recursive: true });
|
|
20
|
+
await fs.writeFile(path.join(dir, "SKILL.md"), `---\nname: demo\nversion: ${version}\n---\n${marker}\n`);
|
|
21
|
+
await fs.writeFile(path.join(dir, "scripts", `${marker}.txt`), marker);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("repairNestedExpertSkillLayouts", () => {
|
|
25
|
+
it("将同名子目录中的新版提升到专家 Skill 根目录", async () => {
|
|
26
|
+
const outer = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
27
|
+
await writeSkill(outer, "2.1.1", "old");
|
|
28
|
+
await writeSkill(path.join(outer, "demo"), "2.1.4", "new");
|
|
29
|
+
|
|
30
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
31
|
+
|
|
32
|
+
assert.deepEqual(result.repaired, [outer]);
|
|
33
|
+
assert.match(await fs.readFile(path.join(outer, "SKILL.md"), "utf8"), /version: 2\.1\.4/);
|
|
34
|
+
assert.equal(await fs.readFile(path.join(outer, "scripts", "new.txt"), "utf8"), "new");
|
|
35
|
+
await assert.rejects(fs.access(path.join(outer, "demo")), { code: "ENOENT" });
|
|
36
|
+
await assert.rejects(fs.access(path.join(outer, "scripts", "old.txt")), { code: "ENOENT" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("外层没有 SKILL.md 时也修复初次安装产生的同名嵌套", async () => {
|
|
40
|
+
const outer = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
41
|
+
await writeSkill(path.join(outer, "demo"), "1.0.0", "nested");
|
|
42
|
+
|
|
43
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
44
|
+
|
|
45
|
+
assert.deepEqual(result.repaired, [outer]);
|
|
46
|
+
assert.match(await fs.readFile(path.join(outer, "SKILL.md"), "utf8"), /version: 1\.0\.0/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("内层版本低于外层时保留现场,避免错误降级", async () => {
|
|
50
|
+
const outer = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
51
|
+
await writeSkill(outer, "2.1.4", "new");
|
|
52
|
+
await writeSkill(path.join(outer, "demo"), "2.1.1", "old");
|
|
53
|
+
|
|
54
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
55
|
+
|
|
56
|
+
assert.deepEqual(result.repaired, []);
|
|
57
|
+
assert.deepEqual(result.skipped, [outer]);
|
|
58
|
+
assert.match(await fs.readFile(path.join(outer, "SKILL.md"), "utf8"), /version: 2\.1\.4/);
|
|
59
|
+
assert.match(await fs.readFile(path.join(outer, "demo", "SKILL.md"), "utf8"), /version: 2\.1\.1/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("修复后重复运行保持幂等,并忽略不符合用户 workspace 规则的目录", async () => {
|
|
63
|
+
const validOuter = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
64
|
+
const invalidOuter = path.join(root, "workspace-assistant-test", ".user", "skills", "demo");
|
|
65
|
+
await writeSkill(validOuter, "1.0.0", "outer");
|
|
66
|
+
await writeSkill(path.join(validOuter, "demo"), "1.0.0", "nested");
|
|
67
|
+
await writeSkill(path.join(invalidOuter, "demo"), "1.0.0", "invalid");
|
|
68
|
+
|
|
69
|
+
const first = await repairNestedExpertSkillLayouts(root);
|
|
70
|
+
const second = await repairNestedExpertSkillLayouts(root);
|
|
71
|
+
|
|
72
|
+
assert.equal(first.repaired.length, 1);
|
|
73
|
+
assert.equal(second.repaired.length, 0);
|
|
74
|
+
assert.equal(second.errors.length, 0);
|
|
75
|
+
assert.equal(await fs.readFile(path.join(invalidOuter, "demo", "SKILL.md"), "utf8"), "---\nname: demo\nversion: 1.0.0\n---\ninvalid\n");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("多层历史异常一次提升到最高版本,不按扫描周期继续拼接或逐层修复", async () => {
|
|
79
|
+
const outer = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
80
|
+
await writeSkill(outer, "2.1.1", "v211");
|
|
81
|
+
await writeSkill(path.join(outer, "demo"), "2.1.4", "v214");
|
|
82
|
+
await writeSkill(path.join(outer, "demo", "demo"), "2.1.5", "v215");
|
|
83
|
+
|
|
84
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
85
|
+
|
|
86
|
+
assert.deepEqual(result.repaired, [outer]);
|
|
87
|
+
assert.match(await fs.readFile(path.join(outer, "SKILL.md"), "utf8"), /version: 2\.1\.5/);
|
|
88
|
+
await assert.rejects(fs.access(path.join(outer, "demo")), { code: "ENOENT" });
|
|
89
|
+
assert.equal((await repairNestedExpertSkillLayouts(root)).repaired.length, 0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("非单调同名链按全链最高版本修复", async () => {
|
|
93
|
+
const outer = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
94
|
+
await writeSkill(outer, "2.1.1", "v211");
|
|
95
|
+
await writeSkill(path.join(outer, "demo"), "2.1.5", "v215");
|
|
96
|
+
await writeSkill(path.join(outer, "demo", "demo"), "2.1.4", "v214");
|
|
97
|
+
|
|
98
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
99
|
+
|
|
100
|
+
assert.deepEqual(result.repaired, [outer]);
|
|
101
|
+
assert.match(await fs.readFile(path.join(outer, "SKILL.md"), "utf8"), /version: 2\.1\.5/);
|
|
102
|
+
await assert.rejects(fs.access(path.join(outer, "demo")), { code: "ENOENT" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("外层版本无法判断时保留现场", async () => {
|
|
106
|
+
const outer = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
107
|
+
await fs.mkdir(outer, { recursive: true });
|
|
108
|
+
await fs.writeFile(path.join(outer, "SKILL.md"), "---\nname: demo\n---\nno version\n");
|
|
109
|
+
await writeSkill(path.join(outer, "demo"), "2.1.4", "new");
|
|
110
|
+
|
|
111
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
112
|
+
|
|
113
|
+
assert.deepEqual(result.repaired, []);
|
|
114
|
+
assert.deepEqual(result.skipped, [outer]);
|
|
115
|
+
assert.match(await fs.readFile(path.join(outer, "demo", "SKILL.md"), "utf8"), /version: 2\.1\.4/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("非数值版本不按字典序臆测新旧,避免自动降级", async () => {
|
|
119
|
+
const outer = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
120
|
+
await writeSkill(outer, "2.10.0", "new");
|
|
121
|
+
await writeSkill(path.join(outer, "demo"), "2.9.x", "old");
|
|
122
|
+
|
|
123
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(result.repaired, []);
|
|
126
|
+
assert.deepEqual(result.skipped, [outer]);
|
|
127
|
+
assert.match(await fs.readFile(path.join(outer, "SKILL.md"), "utf8"), /version: 2\.10\.0/);
|
|
128
|
+
assert.match(await fs.readFile(path.join(outer, "demo", "SKILL.md"), "utf8"), /version: 2\.9\.x/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("被提升内容中的同名普通文件会保留,且不会泄漏 repair-new 残留", async () => {
|
|
132
|
+
const skillsRoot = path.join(root, "workspace-assistant-12345", ".user", "skills");
|
|
133
|
+
const outer = path.join(skillsRoot, "demo");
|
|
134
|
+
const nested = path.join(outer, "demo");
|
|
135
|
+
await writeSkill(outer, "2.1.1", "old");
|
|
136
|
+
await writeSkill(nested, "2.1.4", "new");
|
|
137
|
+
await fs.writeFile(path.join(nested, "demo"), "合法的同名数据文件");
|
|
138
|
+
|
|
139
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
140
|
+
|
|
141
|
+
assert.deepEqual(result.repaired, [outer]);
|
|
142
|
+
assert.equal(await fs.readFile(path.join(outer, "demo"), "utf8"), "合法的同名数据文件");
|
|
143
|
+
assert.deepEqual((await fs.readdir(skillsRoot)).filter((name) => name.includes("repair-new")), []);
|
|
144
|
+
assert.equal((await repairNestedExpertSkillLayouts(root)).repaired.length, 0);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("拒绝跟随同名符号链接,且不修改链接目标", async (t) => {
|
|
148
|
+
if (process.platform === "win32") return t.skip("Windows 创建目录符号链接需要额外权限");
|
|
149
|
+
const outer = path.join(root, "workspace-assistant-12345", ".user", "skills", "demo");
|
|
150
|
+
const external = path.join(root, "external-demo");
|
|
151
|
+
await writeSkill(outer, "2.1.1", "old");
|
|
152
|
+
await writeSkill(external, "9.9.9", "external");
|
|
153
|
+
await fs.symlink(external, path.join(outer, "demo"), "dir");
|
|
154
|
+
|
|
155
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(result.repaired, []);
|
|
158
|
+
assert.equal(result.errors.length, 1);
|
|
159
|
+
assert.match(result.errors[0].message, /符号链接/);
|
|
160
|
+
assert.equal(await fs.readFile(path.join(external, "scripts", "external.txt"), "utf8"), "external");
|
|
161
|
+
assert.equal((await fs.lstat(path.join(outer, "demo"))).isSymbolicLink(), true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("规范目录在换入前崩溃时从 repair-old 残留恢复", async () => {
|
|
165
|
+
const skillsRoot = path.join(root, "workspace-assistant-12345", ".user", "skills");
|
|
166
|
+
const old = path.join(skillsRoot, ".demo.repair-old-100-old");
|
|
167
|
+
const stage = path.join(skillsRoot, ".demo.repair-new-100-new");
|
|
168
|
+
await writeSkill(old, "2.1.1", "old");
|
|
169
|
+
await writeSkill(stage, "2.1.4", "stage");
|
|
170
|
+
|
|
171
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
172
|
+
const target = path.join(skillsRoot, "demo");
|
|
173
|
+
|
|
174
|
+
assert.equal(result.errors.length, 0);
|
|
175
|
+
assert.match(await fs.readFile(path.join(target, "SKILL.md"), "utf8"), /version: 2\.1\.1/);
|
|
176
|
+
await assert.rejects(fs.access(old), { code: "ENOENT" });
|
|
177
|
+
await assert.rejects(fs.access(stage), { code: "ENOENT" });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("规范目录已存在时清理 repair-old 和 repair-new 残留", async () => {
|
|
181
|
+
const skillsRoot = path.join(root, "workspace-assistant-12345", ".user", "skills");
|
|
182
|
+
const target = path.join(skillsRoot, "demo");
|
|
183
|
+
const old = path.join(skillsRoot, ".demo.repair-old-100-old");
|
|
184
|
+
const stage = path.join(skillsRoot, ".demo.repair-new-100-new");
|
|
185
|
+
await writeSkill(target, "2.1.5", "current");
|
|
186
|
+
await writeSkill(old, "2.1.1", "old");
|
|
187
|
+
await writeSkill(stage, "2.1.4", "stage");
|
|
188
|
+
|
|
189
|
+
const result = await repairNestedExpertSkillLayouts(root);
|
|
190
|
+
|
|
191
|
+
assert.equal(result.errors.length, 0);
|
|
192
|
+
assert.match(await fs.readFile(path.join(target, "SKILL.md"), "utf8"), /version: 2\.1\.5/);
|
|
193
|
+
await assert.rejects(fs.access(old), { code: "ENOENT" });
|
|
194
|
+
await assert.rejects(fs.access(stage), { code: "ENOENT" });
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { Dirent } from "node:fs";
|
|
5
|
+
import { compareVersions, isComparableVersion } from "./semver.ts";
|
|
6
|
+
import { readSkillVersion } from "./skill-version.ts";
|
|
7
|
+
|
|
8
|
+
const ASSISTANT_WORKSPACE_RE = /^workspace-assistant-\d{5,}$/;
|
|
9
|
+
const REPAIR_RESIDUE_RE = /^\.(.+)\.repair-(old|new)-(.+)$/;
|
|
10
|
+
|
|
11
|
+
export type ExpertSkillLayoutRepairResult = {
|
|
12
|
+
scannedWorkspaces: number;
|
|
13
|
+
repaired: string[];
|
|
14
|
+
skipped: string[];
|
|
15
|
+
errors: Array<{ path: string; message: string }>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function lstatOrUndefined(filePath: string) {
|
|
19
|
+
try {
|
|
20
|
+
return await fs.lstat(filePath);
|
|
21
|
+
} catch (error: any) {
|
|
22
|
+
if (error?.code === "ENOENT") return undefined;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function isRegularFile(filePath: string): Promise<boolean> {
|
|
28
|
+
const stat = await lstatOrUndefined(filePath);
|
|
29
|
+
return Boolean(stat?.isFile() && !stat.isSymbolicLink());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function removeRealDirectory(dirPath: string): Promise<void> {
|
|
33
|
+
const stat = await lstatOrUndefined(dirPath);
|
|
34
|
+
if (!stat) return;
|
|
35
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
36
|
+
throw new Error(`拒绝清理非真实目录: ${dirPath}`);
|
|
37
|
+
}
|
|
38
|
+
await fs.rm(dirPath, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function removeNestedDirectoryIfPresent(dirPath: string): Promise<void> {
|
|
42
|
+
const stat = await lstatOrUndefined(dirPath);
|
|
43
|
+
if (!stat) return;
|
|
44
|
+
if (stat.isSymbolicLink()) throw new Error(`拒绝清理符号链接: ${dirPath}`);
|
|
45
|
+
if (stat.isDirectory()) await fs.rm(dirPath, { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function recoverRepairResidues(
|
|
49
|
+
skillsRoot: string,
|
|
50
|
+
entries: Dirent<string>[],
|
|
51
|
+
result: ExpertSkillLayoutRepairResult
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const groups = new Map<string, { old: string[]; stage: string[] }>();
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const match = REPAIR_RESIDUE_RE.exec(entry.name);
|
|
56
|
+
if (!match) continue;
|
|
57
|
+
const group = groups.get(match[1]) ?? { old: [], stage: [] };
|
|
58
|
+
group[match[2] === "old" ? "old" : "stage"].push(path.join(skillsRoot, entry.name));
|
|
59
|
+
groups.set(match[1], group);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const [code, group] of groups) {
|
|
63
|
+
const targetDir = path.join(skillsRoot, code);
|
|
64
|
+
try {
|
|
65
|
+
const targetStat = await lstatOrUndefined(targetDir);
|
|
66
|
+
if (targetStat?.isSymbolicLink() || (targetStat && !targetStat.isDirectory())) {
|
|
67
|
+
throw new Error(`规范路径不是可安全操作的真实目录: ${targetDir}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
group.old.sort().reverse();
|
|
71
|
+
if (!targetStat && group.old.length > 0) {
|
|
72
|
+
const restore = group.old.shift()!;
|
|
73
|
+
const restoreStat = await lstatOrUndefined(restore);
|
|
74
|
+
if (!restoreStat?.isDirectory() || restoreStat.isSymbolicLink()) {
|
|
75
|
+
throw new Error(`拒绝从非真实目录恢复: ${restore}`);
|
|
76
|
+
}
|
|
77
|
+
await fs.rename(restore, targetDir);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (await lstatOrUndefined(targetDir)) {
|
|
81
|
+
for (const residue of [...group.old, ...group.stage]) {
|
|
82
|
+
await removeRealDirectory(residue);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (error: any) {
|
|
86
|
+
result.errors.push({ path: targetDir, message: error?.message || String(error) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function promoteNestedSkill(sourceDir: string, targetDir: string): Promise<void> {
|
|
92
|
+
const parent = path.dirname(targetDir);
|
|
93
|
+
const tag = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
94
|
+
const stage = path.join(parent, `.${path.basename(targetDir)}.repair-new-${tag}`);
|
|
95
|
+
const old = path.join(parent, `.${path.basename(targetDir)}.repair-old-${tag}`);
|
|
96
|
+
|
|
97
|
+
let movedOld = false;
|
|
98
|
+
try {
|
|
99
|
+
await fs.cp(sourceDir, stage, { recursive: true });
|
|
100
|
+
// 同名子项为目录时是历史嵌套,应删除;普通文件属于 Skill 内容,必须保留。
|
|
101
|
+
await removeNestedDirectoryIfPresent(path.join(stage, path.basename(targetDir)));
|
|
102
|
+
await fs.rename(targetDir, old);
|
|
103
|
+
movedOld = true;
|
|
104
|
+
await fs.rename(stage, targetDir);
|
|
105
|
+
} catch (error: any) {
|
|
106
|
+
const rollbackErrors: string[] = [];
|
|
107
|
+
try {
|
|
108
|
+
await removeRealDirectory(stage);
|
|
109
|
+
} catch (cleanupError: any) {
|
|
110
|
+
rollbackErrors.push(`清理暂存目录失败: ${cleanupError?.message || String(cleanupError)}`);
|
|
111
|
+
}
|
|
112
|
+
if (movedOld) {
|
|
113
|
+
try {
|
|
114
|
+
await fs.rename(old, targetDir);
|
|
115
|
+
} catch (restoreError: any) {
|
|
116
|
+
rollbackErrors.push(`恢复旧目录失败: ${restoreError?.message || String(restoreError)}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (rollbackErrors.length > 0) {
|
|
120
|
+
throw new Error(`${error?.message || String(error)}; ${rollbackErrors.join("; ")}`);
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
await removeRealDirectory(old);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 修复历史 UPDATE_SKILL 将新版写入 `.user/skills/<code>/<code>` 的异常布局。
|
|
129
|
+
* 完整扫描同名目录链并提升全局最高版本;符号链接和版本不明的现场一律保守跳过。
|
|
130
|
+
*/
|
|
131
|
+
export async function repairNestedExpertSkillLayouts(
|
|
132
|
+
openclawRoot: string
|
|
133
|
+
): Promise<ExpertSkillLayoutRepairResult> {
|
|
134
|
+
const result: ExpertSkillLayoutRepairResult = {
|
|
135
|
+
scannedWorkspaces: 0,
|
|
136
|
+
repaired: [],
|
|
137
|
+
skipped: [],
|
|
138
|
+
errors: [],
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
let workspaceEntries;
|
|
142
|
+
try {
|
|
143
|
+
workspaceEntries = await fs.readdir(openclawRoot, { withFileTypes: true });
|
|
144
|
+
} catch (error: any) {
|
|
145
|
+
result.errors.push({ path: openclawRoot, message: error?.message || String(error) });
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const workspaceEntry of workspaceEntries) {
|
|
150
|
+
if (!workspaceEntry.isDirectory() || !ASSISTANT_WORKSPACE_RE.test(workspaceEntry.name)) continue;
|
|
151
|
+
result.scannedWorkspaces += 1;
|
|
152
|
+
const skillsRoot = path.join(openclawRoot, workspaceEntry.name, ".user", "skills");
|
|
153
|
+
let skillEntries;
|
|
154
|
+
try {
|
|
155
|
+
skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
156
|
+
await recoverRepairResidues(skillsRoot, skillEntries, result);
|
|
157
|
+
skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
158
|
+
} catch (error: any) {
|
|
159
|
+
if (error?.code !== "ENOENT") {
|
|
160
|
+
result.errors.push({ path: skillsRoot, message: error?.message || String(error) });
|
|
161
|
+
}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
for (const skillEntry of skillEntries) {
|
|
166
|
+
if (!skillEntry.isDirectory() || skillEntry.name.startsWith(".")) continue;
|
|
167
|
+
const outerDir = path.join(skillsRoot, skillEntry.name);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const candidates: Array<{ dir: string; version: string }> = [];
|
|
171
|
+
const outerHasSkill = await isRegularFile(path.join(outerDir, "SKILL.md"));
|
|
172
|
+
if (outerHasSkill) {
|
|
173
|
+
const version = await readSkillVersion(outerDir);
|
|
174
|
+
if (!version) {
|
|
175
|
+
result.skipped.push(outerDir);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
candidates.push({ dir: outerDir, version });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let cursor = outerDir;
|
|
182
|
+
let nestedCount = 0;
|
|
183
|
+
while (true) {
|
|
184
|
+
const nestedDir = path.join(cursor, skillEntry.name);
|
|
185
|
+
const nestedStat = await lstatOrUndefined(nestedDir);
|
|
186
|
+
if (!nestedStat) break;
|
|
187
|
+
if (nestedStat.isSymbolicLink()) {
|
|
188
|
+
throw new Error(`拒绝跟随同名符号链接: ${nestedDir}`);
|
|
189
|
+
}
|
|
190
|
+
if (!nestedStat.isDirectory()) break;
|
|
191
|
+
cursor = nestedDir;
|
|
192
|
+
|
|
193
|
+
const skillMd = path.join(nestedDir, "SKILL.md");
|
|
194
|
+
const skillStat = await lstatOrUndefined(skillMd);
|
|
195
|
+
if (skillStat?.isSymbolicLink()) {
|
|
196
|
+
throw new Error(`拒绝读取符号链接 SKILL.md: ${skillMd}`);
|
|
197
|
+
}
|
|
198
|
+
if (!skillStat?.isFile()) continue;
|
|
199
|
+
nestedCount += 1;
|
|
200
|
+
const version = await readSkillVersion(nestedDir);
|
|
201
|
+
if (!version) {
|
|
202
|
+
result.skipped.push(outerDir);
|
|
203
|
+
candidates.length = 0;
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
candidates.push({ dir: nestedDir, version });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (nestedCount === 0 || candidates.length === 0) continue;
|
|
210
|
+
if (candidates.some((candidate) => !isComparableVersion(candidate.version))) {
|
|
211
|
+
result.skipped.push(outerDir);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
let source = candidates[0];
|
|
215
|
+
for (const candidate of candidates.slice(1)) {
|
|
216
|
+
// 同版本也选择更深层,确保把异常同名子目录收敛掉。
|
|
217
|
+
if (compareVersions(candidate.version, source.version) >= 0) source = candidate;
|
|
218
|
+
}
|
|
219
|
+
if (source.dir === outerDir) {
|
|
220
|
+
result.skipped.push(outerDir);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await promoteNestedSkill(source.dir, outerDir);
|
|
225
|
+
result.repaired.push(outerDir);
|
|
226
|
+
} catch (error: any) {
|
|
227
|
+
result.errors.push({ path: outerDir, message: error?.message || String(error) });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return result;
|
|
233
|
+
}
|
package/src/global.d.ts
ADDED