@spzhongwin/skill-logger-plugin 1.0.15 → 1.0.17
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 +462 -121
- package/package.json +2 -2
- package/src/expert-skill-layout.test.ts +196 -0
- package/src/expert-skill-layout.ts +233 -0
- package/src/index.ts +26 -5
- package/src/semver.test.ts +11 -1
- package/src/semver.ts +5 -0
- package/src/updater.test.ts +117 -0
- package/src/updater.ts +52 -0
- package/src/ws-client.test.ts +119 -0
- package/src/ws-client.ts +175 -31
- 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
package/package.json
CHANGED
|
@@ -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/index.ts
CHANGED
|
@@ -15,11 +15,12 @@ import path from "node:path";
|
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import type { PluginConfig } from "./types.ts";
|
|
18
|
-
import { resolvePaths } from "./paths.ts";
|
|
18
|
+
import { openclawHome, resolvePaths } from "./paths.ts";
|
|
19
19
|
import { ActiveSkills } from "./active-skills.ts";
|
|
20
20
|
import { SkillUpdater } from "./updater.ts";
|
|
21
21
|
import { ConfigSync } from "./config-sync.ts";
|
|
22
22
|
import { Reporter } from "./reporter.ts";
|
|
23
|
+
import { repairNestedExpertSkillLayouts } from "./expert-skill-layout.ts";
|
|
23
24
|
|
|
24
25
|
// 启动长连接中枢
|
|
25
26
|
import { GatewayWsClient } from "./ws-client.ts";
|
|
@@ -87,6 +88,26 @@ const definition = {
|
|
|
87
88
|
const reporter = new Reporter({ paths, getConfig });
|
|
88
89
|
const hooks = new Hooks(reporter, configSync, activeSkills, getConfig);
|
|
89
90
|
|
|
91
|
+
let expertSkillLayoutRepair: Promise<void> | undefined;
|
|
92
|
+
const repairExpertSkillLayouts = () => {
|
|
93
|
+
if (expertSkillLayoutRepair) return expertSkillLayoutRepair;
|
|
94
|
+
expertSkillLayoutRepair = (async () => {
|
|
95
|
+
const result = await repairNestedExpertSkillLayouts(openclawHome());
|
|
96
|
+
if (result.repaired.length > 0) {
|
|
97
|
+
console.log(`[skill-logger-plugin] 已修复 ${result.repaired.length} 个专家 Skill 嵌套目录`);
|
|
98
|
+
}
|
|
99
|
+
for (const skippedPath of result.skipped) {
|
|
100
|
+
console.warn(`[skill-logger-plugin] 专家 Skill 嵌套目录版本无法安全提升,已保留现场: ${skippedPath}`);
|
|
101
|
+
}
|
|
102
|
+
for (const error of result.errors) {
|
|
103
|
+
console.warn(`[skill-logger-plugin] 修复专家 Skill 目录失败: ${error.path}`, error.message);
|
|
104
|
+
}
|
|
105
|
+
})().finally(() => {
|
|
106
|
+
expertSkillLayoutRepair = undefined;
|
|
107
|
+
});
|
|
108
|
+
return expertSkillLayoutRepair;
|
|
109
|
+
};
|
|
110
|
+
|
|
90
111
|
let reconcileTimer: ReturnType<typeof setInterval> | undefined;
|
|
91
112
|
const sessionUpdatedSkills = new Set<string>();
|
|
92
113
|
|
|
@@ -152,13 +173,13 @@ const definition = {
|
|
|
152
173
|
updater: updater,
|
|
153
174
|
enableFileLog: currentConfig.enableFileLog // 将日志开关透传给客户端模块
|
|
154
175
|
});
|
|
155
|
-
wsClient.connect();
|
|
156
176
|
}
|
|
157
177
|
// =========================================================
|
|
158
178
|
|
|
159
|
-
//
|
|
160
|
-
void
|
|
161
|
-
.
|
|
179
|
+
// 连接中控前先修复存量异常,避免服务端上线重投与目录迁移并发写入同一路径。
|
|
180
|
+
void repairExpertSkillLayouts()
|
|
181
|
+
.then(() => wsClient?.connect())
|
|
182
|
+
.then(() => configSync.load())
|
|
162
183
|
.then(() => configSync.reconcile())
|
|
163
184
|
.catch((err) => console.warn("[skill-logger-plugin] 启动初始化异常", err));
|
|
164
185
|
reporter.startTimer();
|
package/src/semver.test.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, it } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { compareVersions, isOutdated } from "./semver.ts";
|
|
3
|
+
import { compareVersions, isComparableVersion, isOutdated } from "./semver.ts";
|
|
4
|
+
|
|
5
|
+
describe("isComparableVersion", () => {
|
|
6
|
+
it("仅接受可安全进行数值排序的版本", () => {
|
|
7
|
+
assert.equal(isComparableVersion("2.10.0"), true);
|
|
8
|
+
assert.equal(isComparableVersion("v2.10"), true);
|
|
9
|
+
assert.equal(isComparableVersion("2.10.0-beta"), true);
|
|
10
|
+
assert.equal(isComparableVersion("2.9.x"), false);
|
|
11
|
+
assert.equal(isComparableVersion("2.1.4 修订版"), false);
|
|
12
|
+
});
|
|
13
|
+
});
|
|
4
14
|
|
|
5
15
|
describe("compareVersions", () => {
|
|
6
16
|
it("数值逐段比较:1.10 > 1.9", () => assert.equal(compareVersions("1.10.0", "1.9.0"), 1));
|
package/src/semver.ts
CHANGED
|
@@ -21,6 +21,11 @@ function parseCore(v: string): number[] | null {
|
|
|
21
21
|
return nums.length > 0 ? nums : null;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** 是否可按数值版本段安全排序;自动目录迁移不能对其他字符串臆测新旧。 */
|
|
25
|
+
export function isComparableVersion(v: string): boolean {
|
|
26
|
+
return parseCore(v) !== null;
|
|
27
|
+
}
|
|
28
|
+
|
|
24
29
|
/**
|
|
25
30
|
* 比较 a 与 b:a<b 返回 -1,a>b 返回 1,相等返回 0。
|
|
26
31
|
* 两侧都能解析为点分数字时按数值逐段比较;否则退化为字符串比较。
|
package/src/updater.test.ts
CHANGED
|
@@ -72,6 +72,72 @@ const outdatedFor = (target: string): OutdatedCopy[] => [
|
|
|
72
72
|
];
|
|
73
73
|
|
|
74
74
|
describe("SkillUpdater", () => {
|
|
75
|
+
it("手动安装拒绝路径穿越 code,且不下载、不创建越界目录", async () => {
|
|
76
|
+
const calls: string[] = [];
|
|
77
|
+
const skillsRoot = path.join(dir, "workspace", "skills");
|
|
78
|
+
const updater = new SkillUpdater({
|
|
79
|
+
getConfig: () => ({ platformBaseUrl: "https://api" }),
|
|
80
|
+
fetchImpl: makeFetch({ calls }),
|
|
81
|
+
unzip: makeUnzip("1.0.0"),
|
|
82
|
+
tmpDir: dir,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await updater.manualInstall({
|
|
86
|
+
code: "../outside",
|
|
87
|
+
url: "https://pkg.example/skill.zip",
|
|
88
|
+
force: true,
|
|
89
|
+
targetDir: skillsRoot,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
assert.equal(result.success, false);
|
|
93
|
+
assert.match(result.message, /非法的 Skill code/);
|
|
94
|
+
assert.deepEqual(calls, []);
|
|
95
|
+
assert.equal(await fileExists(path.join(dir, "workspace", "outside")), false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("专家安装会剥离 ZIP 顶层目录,并校验专家根文件契约", async () => {
|
|
99
|
+
const expertTarget = path.join(dir, "workspace-assistant-12345", ".user", "experts", "demo");
|
|
100
|
+
const updater = new SkillUpdater({
|
|
101
|
+
getConfig: () => ({ platformBaseUrl: "https://api" }),
|
|
102
|
+
fetchImpl: makeFetch(),
|
|
103
|
+
unzip: async (_zipPath, destDir) => {
|
|
104
|
+
const root = path.join(destDir, "demo-expert");
|
|
105
|
+
await fs.mkdir(root, { recursive: true });
|
|
106
|
+
await fs.writeFile(path.join(root, "AGENTS.md"), "agents");
|
|
107
|
+
await fs.writeFile(path.join(root, "SOUL.md"), "soul");
|
|
108
|
+
},
|
|
109
|
+
tmpDir: dir,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const result = await updater.installExpertZipFromUrl("https://pkg.example/expert.zip", expertTarget);
|
|
113
|
+
|
|
114
|
+
assert.equal(result.success, true);
|
|
115
|
+
assert.equal(await fs.readFile(path.join(expertTarget, "AGENTS.md"), "utf8"), "agents");
|
|
116
|
+
assert.equal(await fs.readFile(path.join(expertTarget, "SOUL.md"), "utf8"), "soul");
|
|
117
|
+
assert.equal(await fileExists(path.join(expertTarget, "demo-expert")), false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("专家安装拒绝缺少根文件的 ZIP,不覆盖旧专家", async () => {
|
|
121
|
+
const expertTarget = path.join(dir, "workspace-assistant-12345", ".user", "experts", "demo");
|
|
122
|
+
await fs.mkdir(expertTarget, { recursive: true });
|
|
123
|
+
await fs.writeFile(path.join(expertTarget, "AGENTS.md"), "old");
|
|
124
|
+
const updater = new SkillUpdater({
|
|
125
|
+
getConfig: () => ({ platformBaseUrl: "https://api" }),
|
|
126
|
+
fetchImpl: makeFetch(),
|
|
127
|
+
unzip: async (_zipPath, destDir) => {
|
|
128
|
+
await fs.mkdir(path.join(destDir, "bad-expert"), { recursive: true });
|
|
129
|
+
await fs.writeFile(path.join(destDir, "bad-expert", "AGENTS.md"), "new");
|
|
130
|
+
},
|
|
131
|
+
tmpDir: dir,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const result = await updater.installExpertZipFromUrl("https://pkg.example/expert.zip", expertTarget);
|
|
135
|
+
|
|
136
|
+
assert.equal(result.success, false);
|
|
137
|
+
assert.match(result.message, /AGENTS\.md 和 SOUL\.md/);
|
|
138
|
+
assert.equal(await fs.readFile(path.join(expertTarget, "AGENTS.md"), "utf8"), "old");
|
|
139
|
+
});
|
|
140
|
+
|
|
75
141
|
it("手动更新可复用同一次下载,同时覆盖用户与内置模板目录并记录关键阶段", async () => {
|
|
76
142
|
const calls: string[] = [];
|
|
77
143
|
const traces: Array<{ stage: string; data?: Record<string, unknown> }> = [];
|
|
@@ -125,6 +191,57 @@ describe("SkillUpdater", () => {
|
|
|
125
191
|
);
|
|
126
192
|
});
|
|
127
193
|
|
|
194
|
+
it("专家 Skill 连续更新始终覆盖单层目录,不会继续拼接 code", async () => {
|
|
195
|
+
const normalSkillsDir = path.join(dir, "workspace-assistant-12345", "skills");
|
|
196
|
+
const expertSkillsRoot = path.join(dir, "workspace-assistant-12345", ".user", "skills");
|
|
197
|
+
const expertSkillDir = path.join(expertSkillsRoot, "demo");
|
|
198
|
+
await fs.mkdir(expertSkillDir, { recursive: true });
|
|
199
|
+
await fs.writeFile(path.join(expertSkillDir, "SKILL.md"), "---\nname: demo\nversion: 2.1.1\n---\nOLD\n");
|
|
200
|
+
|
|
201
|
+
for (const version of ["2.1.4", "2.1.5"]) {
|
|
202
|
+
const updater = new SkillUpdater({
|
|
203
|
+
getConfig: () => ({ platformBaseUrl: "https://api" }),
|
|
204
|
+
fetchImpl: makeFetch(),
|
|
205
|
+
unzip: makeUnzip(version),
|
|
206
|
+
tmpDir: dir,
|
|
207
|
+
});
|
|
208
|
+
const result = await updater.manualInstall({
|
|
209
|
+
code: "demo",
|
|
210
|
+
url: `https://pkg.example/demo-${version}.zip`,
|
|
211
|
+
version,
|
|
212
|
+
force: true,
|
|
213
|
+
targetDir: normalSkillsDir,
|
|
214
|
+
additionalTargetDirs: [expertSkillsRoot],
|
|
215
|
+
});
|
|
216
|
+
assert.equal(result.success, true);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
assert.match(await fs.readFile(path.join(expertSkillDir, "SKILL.md"), "utf8"), /version: 2\.1\.5/);
|
|
220
|
+
await assert.rejects(fs.access(path.join(expertSkillDir, "demo")), { code: "ENOENT" });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("专家依赖首次安装会剥离 ZIP 自带的顶层目录", async () => {
|
|
224
|
+
const expertSkillsRoot = path.join(dir, "workspace-assistant-12345", ".user", "skills");
|
|
225
|
+
const updater = new SkillUpdater({
|
|
226
|
+
getConfig: () => ({ platformBaseUrl: "https://api" }),
|
|
227
|
+
fetchImpl: makeFetch(),
|
|
228
|
+
unzip: makeUnzip("1.0.0"),
|
|
229
|
+
tmpDir: dir,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = await updater.manualInstall({
|
|
233
|
+
code: "demo",
|
|
234
|
+
url: "https://pkg.example/demo-1.0.0.zip",
|
|
235
|
+
version: "1.0.0",
|
|
236
|
+
force: true,
|
|
237
|
+
targetDir: expertSkillsRoot,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
assert.equal(result.success, true);
|
|
241
|
+
assert.match(await fs.readFile(path.join(expertSkillsRoot, "demo", "SKILL.md"), "utf8"), /version: 1\.0\.0/);
|
|
242
|
+
await assert.rejects(fs.access(path.join(expertSkillsRoot, "demo", "skill-root")), { code: "ENOENT" });
|
|
243
|
+
});
|
|
244
|
+
|
|
128
245
|
it("普通手动安装未传附加目录时只覆盖用户目录", async () => {
|
|
129
246
|
const userSkillsDir = path.join(dir, "workspace-assistant-12345", "skills");
|
|
130
247
|
const templateSkillsDir = path.join(dir, "workspace-xgjk-assistant-template", "skills");
|