@spzhongwin/skill-logger-plugin 1.0.8 → 1.0.10

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.
Files changed (54) hide show
  1. package/openclaw.plugin.json +50 -50
  2. package/package.json +37 -37
  3. package/src/active-skills.test.ts +32 -32
  4. package/src/active-skills.ts +77 -77
  5. package/src/config-sync.test.ts +165 -165
  6. package/src/config-sync.ts +544 -544
  7. package/src/hooks.test.ts +251 -251
  8. package/src/hooks.ts +517 -517
  9. package/src/http.ts +61 -61
  10. package/src/identity.ts +64 -64
  11. package/src/index.test.ts +53 -53
  12. package/src/index.ts +226 -226
  13. package/src/integration.test.ts +119 -119
  14. package/src/matcher.test.ts +170 -170
  15. package/src/matcher.ts +393 -393
  16. package/src/paths.test.ts +57 -57
  17. package/src/paths.ts +84 -84
  18. package/src/reporter.test.ts +139 -139
  19. package/src/reporter.ts +298 -298
  20. package/src/sample-config.json +72 -72
  21. package/src/semver.test.ts +23 -23
  22. package/src/semver.ts +60 -60
  23. package/src/skill-version.ts +53 -53
  24. package/src/types.ts +198 -198
  25. package/src/updater.test.ts +237 -237
  26. package/src/updater.ts +433 -406
  27. package/src/ws-client.test.ts +37 -37
  28. package/src/ws-client.ts +642 -598
  29. package/test-ws.ts +17 -17
  30. package/tsconfig.json +14 -14
  31. package/dist/active-skills.js +0 -67
  32. package/dist/active-skills.test.js +0 -29
  33. package/dist/config-sync.js +0 -439
  34. package/dist/config-sync.test.js +0 -145
  35. package/dist/hooks.js +0 -337
  36. package/dist/hooks.test.js +0 -123
  37. package/dist/http.js +0 -54
  38. package/dist/identity.js +0 -56
  39. package/dist/index.js +0 -2564
  40. package/dist/index.test.js +0 -39
  41. package/dist/integration.test.js +0 -102
  42. package/dist/matcher.js +0 -362
  43. package/dist/matcher.test.js +0 -139
  44. package/dist/paths.js +0 -62
  45. package/dist/paths.test.js +0 -49
  46. package/dist/reporter.js +0 -267
  47. package/dist/reporter.test.js +0 -128
  48. package/dist/semver.js +0 -64
  49. package/dist/semver.test.js +0 -21
  50. package/dist/skill-version.js +0 -23
  51. package/dist/types.js +0 -9
  52. package/dist/updater.js +0 -352
  53. package/dist/updater.test.js +0 -212
  54. package/dist/ws-client.js +0 -484
@@ -1,237 +1,237 @@
1
- import { describe, it, beforeEach, afterEach } from "node:test";
2
- import assert from "node:assert/strict";
3
- import fs from "node:fs/promises";
4
- import path from "node:path";
5
- import os from "node:os";
6
- import { createHash } from "node:crypto";
7
- import { SkillUpdater, type OutdatedCopy } from "./updater.ts";
8
- import type { PluginConfig } from "./types.ts";
9
-
10
- /** 与 updater/服务端一致的身份哈希:code code version(空格分隔)。 */
11
- const idHash = (code: string, version: string) =>
12
- createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
13
-
14
- let dir: string;
15
-
16
- beforeEach(async () => {
17
- dir = path.join(os.tmpdir(), `slp-updater-${Date.now()}-${Math.random().toString(36).slice(2)}`);
18
- await fs.mkdir(dir, { recursive: true });
19
- });
20
-
21
- afterEach(async () => {
22
- await fs.rm(dir, { recursive: true, force: true });
23
- });
24
-
25
- /** 注入的假 fetch:POST 取下载地址返回 {url},GET 下载返回固定 zip 字节。 */
26
- function makeFetch(opts: { urlOk?: boolean; downloadOk?: boolean; calls?: string[] } = {}) {
27
- const calls = opts.calls ?? [];
28
- return async (url: string, init?: RequestInit) => {
29
- calls.push(url);
30
- if (init?.method === "POST") {
31
- return {
32
- ok: opts.urlOk !== false,
33
- status: opts.urlOk !== false ? 200 : 500,
34
- json: async () => ({ url: "https://pkg.example/skill.zip" }),
35
- arrayBuffer: async () => new ArrayBuffer(0),
36
- };
37
- }
38
- // GET 下载
39
- return {
40
- ok: opts.downloadOk !== false,
41
- status: opts.downloadOk !== false ? 200 : 500,
42
- json: async () => ({}),
43
- arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
44
- };
45
- };
46
- }
47
-
48
- /** 注入的假 unzip:把「新版本」内容写进 staging(含 SKILL.md),模拟解压结果。 */
49
- function makeUnzip(newVersion: string, withSkillMd = true) {
50
- return async (_zipPath: string, destDir: string) => {
51
- const root = path.join(destDir, "skill-root");
52
- await fs.mkdir(root, { recursive: true });
53
- if (withSkillMd) {
54
- await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${newVersion}\n---\nNEW\n`);
55
- await fs.writeFile(path.join(root, "new-file.txt"), "added by update");
56
- } else {
57
- await fs.writeFile(path.join(root, "note.txt"), "no skill md here");
58
- }
59
- };
60
- }
61
-
62
- async function makeTarget(name: string): Promise<string> {
63
- const target = path.join(dir, "workspace", "skills", name);
64
- await fs.mkdir(target, { recursive: true });
65
- await fs.writeFile(path.join(target, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
66
- await fs.writeFile(path.join(target, "old-file.txt"), "should be gone after update");
67
- return target;
68
- }
69
-
70
- const outdatedFor = (target: string): OutdatedCopy[] => [
71
- { skillName: "demo", rootDir: target, localVersion: "1.0.0", latestVersion: "2.0.0" },
72
- ];
73
-
74
- describe("SkillUpdater", () => {
75
- it("autoUpdateSkills 显式 false:不发请求、不动文件", async () => {
76
- const target = await makeTarget("demo");
77
- const calls: string[] = [];
78
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: false };
79
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
80
- await u.applyUpdates(outdatedFor(target));
81
- assert.equal(calls.length, 0);
82
- assert.equal((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), true);
83
- });
84
-
85
- it("autoUpdateSkills 未设置(默认开启):执行更新", async () => {
86
- const target = await makeTarget("demo");
87
- const cfg: PluginConfig = { platformBaseUrl: "https://api" }; // 不设 autoUpdateSkills → 默认开
88
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
89
- await u.applyUpdates(outdatedFor(target));
90
- assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("NEW"), "默认应执行更新");
91
- });
92
-
93
- it("未配置 platformBaseUrl:不更新", async () => {
94
- const target = await makeTarget("demo");
95
- const cfg: PluginConfig = {}; // 无平台地址
96
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
97
- await u.applyUpdates(outdatedFor(target));
98
- assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"));
99
- });
100
-
101
- it("开启后:下载→解压→覆盖目标目录(直接覆盖,旧文件消失,无 .bak)", async () => {
102
- const target = await makeTarget("demo");
103
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
104
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
105
- await u.applyUpdates(outdatedFor(target));
106
-
107
- const skillMd = await fs.readFile(path.join(target, "SKILL.md"), "utf-8");
108
- assert.ok(skillMd.includes("NEW"), "SKILL.md 应被更新为新内容");
109
- assert.ok(skillMd.includes("version: 2.0.0"), "版本应升级");
110
- assert.ok(await fileExists(path.join(target, "new-file.txt")), "新增文件应出现");
111
- assert.equal(await fileExists(path.join(target, "old-file.txt")), false, "旧文件应被覆盖移除");
112
- // 不留备份
113
- const siblings = await fs.readdir(path.join(dir, "workspace", "skills"));
114
- assert.ok(!siblings.some((f) => f.includes(".bak")), "不应留下 .bak 备份");
115
- });
116
-
117
- it("一个 skill 装在多个 workspace:仅下载一次,覆盖全部副本", async () => {
118
- const t1 = await makeTarget("demo");
119
- const t2Dir = path.join(dir, "workspace-coder", "skills", "demo");
120
- await fs.mkdir(t2Dir, { recursive: true });
121
- await fs.writeFile(path.join(t2Dir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD2\n");
122
-
123
- const calls: string[] = [];
124
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
125
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
126
- await u.applyUpdates([
127
- { skillName: "demo", rootDir: t1, localVersion: "1.0.0", latestVersion: "2.0.0" },
128
- { skillName: "demo", rootDir: t2Dir, localVersion: "1.0.0", latestVersion: "2.0.0" },
129
- ]);
130
-
131
- const postCalls = calls.filter((c) => c.endsWith("/skill_package/pull"));
132
- assert.equal(postCalls.length, 1, "同 skill@version 只取一次下载地址");
133
- assert.ok((await fs.readFile(path.join(t1, "SKILL.md"), "utf-8")).includes("NEW"));
134
- assert.ok((await fs.readFile(path.join(t2Dir, "SKILL.md"), "utf-8")).includes("NEW"));
135
- });
136
-
137
- it("下载包 SKILL.md 无版本号:放弃覆盖(服务端要求无版本不更新)", async () => {
138
- const target = await makeTarget("demo");
139
- // 解压出的 SKILL.md 不含版本号
140
- const unzipNoVersion = async (_z: string, destDir: string) => {
141
- const root = path.join(destDir, "skill-root");
142
- await fs.mkdir(root, { recursive: true });
143
- await fs.writeFile(path.join(root, "SKILL.md"), "---\nname: demo\n---\nNEW but no version\n");
144
- };
145
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
146
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: unzipNoVersion, tmpDir: dir });
147
- await u.applyUpdates(outdatedFor(target));
148
- assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "无版本号时应保持原样");
149
- });
150
-
151
- /** 构造一个 POST 返回指定 sha256 的 fetch。 */
152
- const makeFetchWithHash = (sha256: string) => async (url: string, init?: RequestInit) => {
153
- if (init?.method === "POST") {
154
- return {
155
- ok: true,
156
- status: 200,
157
- json: async () => ({ url: "https://pkg.example/skill.zip", sha256 }),
158
- arrayBuffer: async () => new ArrayBuffer(0),
159
- };
160
- }
161
- return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
162
- };
163
-
164
- it("身份哈希一致:正常覆盖", async () => {
165
- const target = await makeTarget("demo");
166
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
167
- // 服务端按 demo + 包内版本 2.0.0 计算哈希;包内 SKILL.md 也是 2.0.0 → 一致
168
- const u = new SkillUpdater({
169
- getConfig: () => cfg,
170
- fetchImpl: makeFetchWithHash(idHash("demo", "2.0.0")),
171
- unzip: makeUnzip("2.0.0"),
172
- tmpDir: dir,
173
- });
174
- await u.applyUpdates(outdatedFor(target));
175
- assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("NEW"), "哈希一致应覆盖");
176
- });
177
-
178
- it("身份哈希不一致:放弃覆盖", async () => {
179
- const target = await makeTarget("demo");
180
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
181
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetchWithHash("deadbeef"), unzip: makeUnzip("2.0.0"), tmpDir: dir });
182
- await u.applyUpdates(outdatedFor(target));
183
- assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "校验失败应保持原样");
184
- });
185
-
186
- it("包内版本与服务端声称不符(哈希对不上):放弃覆盖", async () => {
187
- const target = await makeTarget("demo");
188
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
189
- // 服务端按声称的最新版本 2.0.0 算哈希,但下载包实际是 9.9.9 → 包内版本算出的哈希对不上
190
- const u = new SkillUpdater({
191
- getConfig: () => cfg,
192
- fetchImpl: makeFetchWithHash(idHash("demo", "2.0.0")),
193
- unzip: makeUnzip("9.9.9"),
194
- tmpDir: dir,
195
- });
196
- await u.applyUpdates(outdatedFor(target));
197
- assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "版本错配应放弃覆盖");
198
- });
199
-
200
- it("下载包内无 SKILL.md:放弃覆盖,目标目录保持原样", async () => {
201
- const target = await makeTarget("demo");
202
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
203
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0", false), tmpDir: dir });
204
- await u.applyUpdates(outdatedFor(target));
205
- assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "目标应保持旧内容");
206
- assert.ok(await fileExists(path.join(target, "old-file.txt")), "目标旧文件应仍在");
207
- });
208
-
209
- it("冷却:同 skill@version 失败后短期内不重复尝试", async () => {
210
- const target = await makeTarget("demo");
211
- const calls: string[] = [];
212
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
213
- // 取下载地址一直失败 → 每次尝试都不会成功
214
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ urlOk: false, calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
215
- await u.applyUpdates(outdatedFor(target));
216
- await u.applyUpdates(outdatedFor(target)); // 第二轮应被冷却拦下
217
- const postCalls = calls.filter((c) => c.endsWith("/skill_package/pull"));
218
- assert.equal(postCalls.length, 1, "冷却期内不应重复发起下载");
219
- });
220
-
221
- it("取下载地址失败:不动目标目录", async () => {
222
- const target = await makeTarget("demo");
223
- const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
224
- const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ urlOk: false }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
225
- await u.applyUpdates(outdatedFor(target));
226
- assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"));
227
- });
228
- });
229
-
230
- async function fileExists(p: string): Promise<boolean> {
231
- try {
232
- await fs.access(p);
233
- return true;
234
- } catch {
235
- return false;
236
- }
237
- }
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ import { SkillUpdater, type OutdatedCopy } from "./updater.ts";
8
+ import type { PluginConfig } from "./types.ts";
9
+
10
+ /** 与 updater/服务端一致的身份哈希:code code version(空格分隔)。 */
11
+ const idHash = (code: string, version: string) =>
12
+ createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
13
+
14
+ let dir: string;
15
+
16
+ beforeEach(async () => {
17
+ dir = path.join(os.tmpdir(), `slp-updater-${Date.now()}-${Math.random().toString(36).slice(2)}`);
18
+ await fs.mkdir(dir, { recursive: true });
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await fs.rm(dir, { recursive: true, force: true });
23
+ });
24
+
25
+ /** 注入的假 fetch:POST 取下载地址返回 {url},GET 下载返回固定 zip 字节。 */
26
+ function makeFetch(opts: { urlOk?: boolean; downloadOk?: boolean; calls?: string[] } = {}) {
27
+ const calls = opts.calls ?? [];
28
+ return async (url: string, init?: RequestInit) => {
29
+ calls.push(url);
30
+ if (init?.method === "POST") {
31
+ return {
32
+ ok: opts.urlOk !== false,
33
+ status: opts.urlOk !== false ? 200 : 500,
34
+ json: async () => ({ url: "https://pkg.example/skill.zip" }),
35
+ arrayBuffer: async () => new ArrayBuffer(0),
36
+ };
37
+ }
38
+ // GET 下载
39
+ return {
40
+ ok: opts.downloadOk !== false,
41
+ status: opts.downloadOk !== false ? 200 : 500,
42
+ json: async () => ({}),
43
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
44
+ };
45
+ };
46
+ }
47
+
48
+ /** 注入的假 unzip:把「新版本」内容写进 staging(含 SKILL.md),模拟解压结果。 */
49
+ function makeUnzip(newVersion: string, withSkillMd = true) {
50
+ return async (_zipPath: string, destDir: string) => {
51
+ const root = path.join(destDir, "skill-root");
52
+ await fs.mkdir(root, { recursive: true });
53
+ if (withSkillMd) {
54
+ await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${newVersion}\n---\nNEW\n`);
55
+ await fs.writeFile(path.join(root, "new-file.txt"), "added by update");
56
+ } else {
57
+ await fs.writeFile(path.join(root, "note.txt"), "no skill md here");
58
+ }
59
+ };
60
+ }
61
+
62
+ async function makeTarget(name: string): Promise<string> {
63
+ const target = path.join(dir, "workspace", "skills", name);
64
+ await fs.mkdir(target, { recursive: true });
65
+ await fs.writeFile(path.join(target, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
66
+ await fs.writeFile(path.join(target, "old-file.txt"), "should be gone after update");
67
+ return target;
68
+ }
69
+
70
+ const outdatedFor = (target: string): OutdatedCopy[] => [
71
+ { skillName: "demo", rootDir: target, localVersion: "1.0.0", latestVersion: "2.0.0" },
72
+ ];
73
+
74
+ describe("SkillUpdater", () => {
75
+ it("autoUpdateSkills 显式 false:不发请求、不动文件", async () => {
76
+ const target = await makeTarget("demo");
77
+ const calls: string[] = [];
78
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: false };
79
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
80
+ await u.applyUpdates(outdatedFor(target));
81
+ assert.equal(calls.length, 0);
82
+ assert.equal((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), true);
83
+ });
84
+
85
+ it("autoUpdateSkills 未设置(默认开启):执行更新", async () => {
86
+ const target = await makeTarget("demo");
87
+ const cfg: PluginConfig = { platformBaseUrl: "https://api" }; // 不设 autoUpdateSkills → 默认开
88
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
89
+ await u.applyUpdates(outdatedFor(target));
90
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("NEW"), "默认应执行更新");
91
+ });
92
+
93
+ it("未配置 platformBaseUrl:不更新", async () => {
94
+ const target = await makeTarget("demo");
95
+ const cfg: PluginConfig = {}; // 无平台地址
96
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
97
+ await u.applyUpdates(outdatedFor(target));
98
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"));
99
+ });
100
+
101
+ it("开启后:下载→解压→覆盖目标目录(直接覆盖,旧文件消失,无 .bak)", async () => {
102
+ const target = await makeTarget("demo");
103
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
104
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
105
+ await u.applyUpdates(outdatedFor(target));
106
+
107
+ const skillMd = await fs.readFile(path.join(target, "SKILL.md"), "utf-8");
108
+ assert.ok(skillMd.includes("NEW"), "SKILL.md 应被更新为新内容");
109
+ assert.ok(skillMd.includes("version: 2.0.0"), "版本应升级");
110
+ assert.ok(await fileExists(path.join(target, "new-file.txt")), "新增文件应出现");
111
+ assert.equal(await fileExists(path.join(target, "old-file.txt")), false, "旧文件应被覆盖移除");
112
+ // 不留备份
113
+ const siblings = await fs.readdir(path.join(dir, "workspace", "skills"));
114
+ assert.ok(!siblings.some((f) => f.includes(".bak")), "不应留下 .bak 备份");
115
+ });
116
+
117
+ it("一个 skill 装在多个 workspace:仅下载一次,覆盖全部副本", async () => {
118
+ const t1 = await makeTarget("demo");
119
+ const t2Dir = path.join(dir, "workspace-coder", "skills", "demo");
120
+ await fs.mkdir(t2Dir, { recursive: true });
121
+ await fs.writeFile(path.join(t2Dir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD2\n");
122
+
123
+ const calls: string[] = [];
124
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
125
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
126
+ await u.applyUpdates([
127
+ { skillName: "demo", rootDir: t1, localVersion: "1.0.0", latestVersion: "2.0.0" },
128
+ { skillName: "demo", rootDir: t2Dir, localVersion: "1.0.0", latestVersion: "2.0.0" },
129
+ ]);
130
+
131
+ const postCalls = calls.filter((c) => c.endsWith("/skill_package/pull"));
132
+ assert.equal(postCalls.length, 1, "同 skill@version 只取一次下载地址");
133
+ assert.ok((await fs.readFile(path.join(t1, "SKILL.md"), "utf-8")).includes("NEW"));
134
+ assert.ok((await fs.readFile(path.join(t2Dir, "SKILL.md"), "utf-8")).includes("NEW"));
135
+ });
136
+
137
+ it("下载包 SKILL.md 无版本号:放弃覆盖(服务端要求无版本不更新)", async () => {
138
+ const target = await makeTarget("demo");
139
+ // 解压出的 SKILL.md 不含版本号
140
+ const unzipNoVersion = async (_z: string, destDir: string) => {
141
+ const root = path.join(destDir, "skill-root");
142
+ await fs.mkdir(root, { recursive: true });
143
+ await fs.writeFile(path.join(root, "SKILL.md"), "---\nname: demo\n---\nNEW but no version\n");
144
+ };
145
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
146
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: unzipNoVersion, tmpDir: dir });
147
+ await u.applyUpdates(outdatedFor(target));
148
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "无版本号时应保持原样");
149
+ });
150
+
151
+ /** 构造一个 POST 返回指定 sha256 的 fetch。 */
152
+ const makeFetchWithHash = (sha256: string) => async (url: string, init?: RequestInit) => {
153
+ if (init?.method === "POST") {
154
+ return {
155
+ ok: true,
156
+ status: 200,
157
+ json: async () => ({ url: "https://pkg.example/skill.zip", sha256 }),
158
+ arrayBuffer: async () => new ArrayBuffer(0),
159
+ };
160
+ }
161
+ return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
162
+ };
163
+
164
+ it("身份哈希一致:正常覆盖", async () => {
165
+ const target = await makeTarget("demo");
166
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
167
+ // 服务端按 demo + 包内版本 2.0.0 计算哈希;包内 SKILL.md 也是 2.0.0 → 一致
168
+ const u = new SkillUpdater({
169
+ getConfig: () => cfg,
170
+ fetchImpl: makeFetchWithHash(idHash("demo", "2.0.0")),
171
+ unzip: makeUnzip("2.0.0"),
172
+ tmpDir: dir,
173
+ });
174
+ await u.applyUpdates(outdatedFor(target));
175
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("NEW"), "哈希一致应覆盖");
176
+ });
177
+
178
+ it("身份哈希不一致:放弃覆盖", async () => {
179
+ const target = await makeTarget("demo");
180
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
181
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetchWithHash("deadbeef"), unzip: makeUnzip("2.0.0"), tmpDir: dir });
182
+ await u.applyUpdates(outdatedFor(target));
183
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "校验失败应保持原样");
184
+ });
185
+
186
+ it("包内版本与服务端声称不符(哈希对不上):放弃覆盖", async () => {
187
+ const target = await makeTarget("demo");
188
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
189
+ // 服务端按声称的最新版本 2.0.0 算哈希,但下载包实际是 9.9.9 → 包内版本算出的哈希对不上
190
+ const u = new SkillUpdater({
191
+ getConfig: () => cfg,
192
+ fetchImpl: makeFetchWithHash(idHash("demo", "2.0.0")),
193
+ unzip: makeUnzip("9.9.9"),
194
+ tmpDir: dir,
195
+ });
196
+ await u.applyUpdates(outdatedFor(target));
197
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "版本错配应放弃覆盖");
198
+ });
199
+
200
+ it("下载包内无 SKILL.md:放弃覆盖,目标目录保持原样", async () => {
201
+ const target = await makeTarget("demo");
202
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
203
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0", false), tmpDir: dir });
204
+ await u.applyUpdates(outdatedFor(target));
205
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "目标应保持旧内容");
206
+ assert.ok(await fileExists(path.join(target, "old-file.txt")), "目标旧文件应仍在");
207
+ });
208
+
209
+ it("冷却:同 skill@version 失败后短期内不重复尝试", async () => {
210
+ const target = await makeTarget("demo");
211
+ const calls: string[] = [];
212
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
213
+ // 取下载地址一直失败 → 每次尝试都不会成功
214
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ urlOk: false, calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
215
+ await u.applyUpdates(outdatedFor(target));
216
+ await u.applyUpdates(outdatedFor(target)); // 第二轮应被冷却拦下
217
+ const postCalls = calls.filter((c) => c.endsWith("/skill_package/pull"));
218
+ assert.equal(postCalls.length, 1, "冷却期内不应重复发起下载");
219
+ });
220
+
221
+ it("取下载地址失败:不动目标目录", async () => {
222
+ const target = await makeTarget("demo");
223
+ const cfg: PluginConfig = { platformBaseUrl: "https://api", autoUpdateSkills: true };
224
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ urlOk: false }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
225
+ await u.applyUpdates(outdatedFor(target));
226
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"));
227
+ });
228
+ });
229
+
230
+ async function fileExists(p: string): Promise<boolean> {
231
+ try {
232
+ await fs.access(p);
233
+ return true;
234
+ } catch {
235
+ return false;
236
+ }
237
+ }