@spzhongwin/skill-logger-plugin 1.0.11 → 1.0.13

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/dist/active-skills.js +67 -0
  2. package/dist/active-skills.test.js +29 -0
  3. package/dist/config-sync.js +439 -0
  4. package/dist/config-sync.test.js +145 -0
  5. package/dist/hooks.js +337 -0
  6. package/dist/hooks.test.js +123 -0
  7. package/dist/http.js +54 -0
  8. package/dist/identity.js +56 -0
  9. package/dist/index.js +240 -78
  10. package/dist/index.test.js +39 -0
  11. package/dist/integration.test.js +102 -0
  12. package/dist/matcher.js +362 -0
  13. package/dist/matcher.test.js +139 -0
  14. package/dist/paths.js +62 -0
  15. package/dist/paths.test.js +49 -0
  16. package/dist/reporter.js +267 -0
  17. package/dist/reporter.test.js +128 -0
  18. package/dist/semver.js +64 -0
  19. package/dist/semver.test.js +21 -0
  20. package/dist/skill-version.js +23 -0
  21. package/dist/types.js +9 -0
  22. package/dist/updater.js +352 -0
  23. package/dist/updater.test.js +212 -0
  24. package/dist/ws-client.js +484 -0
  25. package/openclaw.plugin.json +50 -50
  26. package/package.json +37 -37
  27. package/src/active-skills.test.ts +32 -32
  28. package/src/active-skills.ts +77 -77
  29. package/src/config-sync.test.ts +165 -165
  30. package/src/config-sync.ts +544 -544
  31. package/src/hooks.test.ts +251 -251
  32. package/src/hooks.ts +517 -517
  33. package/src/http.ts +61 -61
  34. package/src/identity.ts +64 -64
  35. package/src/index.test.ts +53 -53
  36. package/src/index.ts +226 -226
  37. package/src/integration.test.ts +119 -119
  38. package/src/matcher.test.ts +170 -170
  39. package/src/matcher.ts +393 -393
  40. package/src/paths.test.ts +57 -57
  41. package/src/paths.ts +84 -84
  42. package/src/reporter.test.ts +139 -139
  43. package/src/reporter.ts +298 -298
  44. package/src/sample-config.json +72 -72
  45. package/src/semver.test.ts +23 -23
  46. package/src/semver.ts +60 -60
  47. package/src/skill-version.ts +53 -53
  48. package/src/types.ts +198 -198
  49. package/src/updater.test.ts +325 -237
  50. package/src/updater.ts +549 -433
  51. package/src/ws-client.test.ts +48 -37
  52. package/src/ws-client.ts +717 -642
  53. package/test-ws.ts +17 -17
  54. package/tsconfig.json +14 -14
@@ -0,0 +1,352 @@
1
+ /**
2
+ * skill 版本自动更新器。
3
+ *
4
+ * 触发:reconcile 检测到本地某 skill 版本落后于平台最新版本(config.version)后调用。
5
+ * 流程(每个 skill@version 仅下载一次,覆盖其全部 workspace 副本):
6
+ * 1. POST {platformBaseUrl}/skill_package/pull {skillName, version} → { url }
7
+ * 2. GET url → zip 字节 → 写入临时文件
8
+ * 3. 解压到临时 staging 目录(默认调用系统 unzip)
9
+ * 4. 在 staging 内定位含 SKILL.md 的目录作为源根(兼容包内带或不带顶层目录)
10
+ * 5. 逐个目标目录「直接覆盖、不备份」:先复制到同级临时目录,再删旧、原子 rename 换入,
11
+ * 避免中途失败留下损坏的半成品;不保留 .bak。
12
+ *
13
+ * 安全约束:
14
+ * - 仅在 autoUpdateSkills===true 且配置了 platformBaseUrl 时执行,默认关闭。
15
+ * - 任一步失败只 warn 并跳过该 skill,绝不抛、绝不阻塞 reconcile。
16
+ * - 下载包内必须含 SKILL.md 才会覆盖,否则放弃(防止把目标目录清空成坏内容)。
17
+ */
18
+ import fs from "node:fs/promises";
19
+ import path from "node:path";
20
+ import os from "node:os";
21
+ import { execFile } from "node:child_process";
22
+ import { promisify } from "node:util";
23
+ import { randomUUID, createHash } from "node:crypto";
24
+ import { parseSkillVersion } from "./skill-version.ts";
25
+ const execFileAsync = promisify(execFile);
26
+ /**
27
+ * 同一 skill@version 的更新尝试冷却期。
28
+ * 防止「下载包版本号与平台 latestVersion 不一致 / 非 semver 版本永不相等」等情况下,
29
+ * 每 3 分钟一轮 reconcile 反复下载覆盖。成功更新会改变本地版本从而不再判过期,故冷却只影响失败/不一致场景。
30
+ */
31
+ const ATTEMPT_COOLDOWN_MS = 30 * 60 * 1000;
32
+ /**
33
+ * 轻量「身份哈希」:name + code + version。约定 name==code(SKILL.md 的 name 即 skillCode),
34
+ * 故按 `code code version`(空格分隔)计算 sha256。与服务端 skillIdentityHash 必须保持一致。
35
+ * 仅做身份/版本一致性校验,不做整包字节哈希。
36
+ */
37
+ function skillIdentityHash(code, version) {
38
+ return createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
39
+ }
40
+ /** 默认解压:系统 unzip -o -q(覆盖、静默)。 */
41
+ async function systemUnzip(zipPath, destDir) {
42
+ await fs.mkdir(destDir, { recursive: true });
43
+ await execFileAsync("unzip", ["-o", "-q", zipPath, "-d", destDir]);
44
+ }
45
+ export class SkillUpdater {
46
+ getConfig;
47
+ fetchImpl;
48
+ unzip;
49
+ tmpDir;
50
+ /** 冷却期状态持久化文件路径。 */
51
+ cooldownStatePath;
52
+ /** skill@version → 上次尝试时间戳,用于冷却,避免重复下载覆盖。 */
53
+ lastAttempt = new Map();
54
+ constructor(opts) {
55
+ this.getConfig = opts.getConfig;
56
+ this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
57
+ this.unzip = opts.unzip ?? systemUnzip;
58
+ this.tmpDir = opts.tmpDir ?? os.tmpdir();
59
+ this.cooldownStatePath = opts.cooldownStatePath;
60
+ this.loadCooldown(); // 同步读取,启动早期就需要用到冷却期
61
+ }
62
+ get isDebug() {
63
+ return this.getConfig().debugLogging !== false;
64
+ }
65
+ debug(...args) {
66
+ if (this.isDebug)
67
+ console.log("[skill-logger-plugin/updater]", ...args);
68
+ }
69
+ /** 从文件同步加载冷却期状态到内存Map。文件不存在或解析失败时静默跳过。 */
70
+ loadCooldown() {
71
+ if (!this.cooldownStatePath)
72
+ return;
73
+ try {
74
+ const raw = fs.readFileSync(this.cooldownStatePath, "utf-8");
75
+ const obj = JSON.parse(raw);
76
+ for (const [k, v] of Object.entries(obj)) {
77
+ if (typeof v === "number" && Number.isFinite(v)) {
78
+ this.lastAttempt.set(k, v);
79
+ }
80
+ }
81
+ this.debug(`冷却期状态已从文件恢复,共 ${this.lastAttempt.size} 条`);
82
+ }
83
+ catch {
84
+ // 文件不存在或解析失败 → 用空Map,插件继续正常工作
85
+ }
86
+ }
87
+ /** 将内存中的冷却期状态写入文件。写入失败时静默跳过,不影响主流程。 */
88
+ async persistCooldown() {
89
+ if (!this.cooldownStatePath)
90
+ return;
91
+ try {
92
+ const obj = {};
93
+ for (const [k, v] of this.lastAttempt) {
94
+ obj[k] = v;
95
+ }
96
+ await fs.writeFile(this.cooldownStatePath, JSON.stringify(obj), "utf-8");
97
+ }
98
+ catch {
99
+ // 写入失败不影响主流程,下次会继续尝试
100
+ }
101
+ }
102
+ /** 对一批落后副本执行更新。按 skill@version 去重下载,覆盖各自的全部目标目录。 */
103
+ async applyUpdates(outdated) {
104
+ const cfg = this.getConfig();
105
+ if (cfg.autoUpdateSkills === false)
106
+ return; // 默认开启,仅显式 false 关闭
107
+ if (!cfg.platformBaseUrl)
108
+ return; // 无平台地址无法下载
109
+ if (outdated.length === 0)
110
+ return;
111
+ const groups = new Map();
112
+ for (const o of outdated) {
113
+ const key = `${o.skillName}@${o.latestVersion}`;
114
+ let g = groups.get(key);
115
+ if (!g) {
116
+ g = { skillName: o.skillName, version: o.latestVersion, targets: [] };
117
+ groups.set(key, g);
118
+ }
119
+ if (!g.targets.includes(o.rootDir))
120
+ g.targets.push(o.rootDir);
121
+ }
122
+ for (const g of groups.values()) {
123
+ const key = `${g.skillName}@${g.version}`;
124
+ const last = this.lastAttempt.get(key);
125
+ if (last !== undefined && Date.now() - last < ATTEMPT_COOLDOWN_MS) {
126
+ this.debug(`跳过 ${key}:距上次尝试不足冷却期(防重复下载覆盖)`);
127
+ continue;
128
+ }
129
+ this.lastAttempt.set(key, Date.now()); // 不论成败都记,失败也按冷却退避,避免每 3 分钟重试
130
+ await this.persistCooldown(); // 持久化到文件,重启后冷却期不丢失
131
+ try {
132
+ await this.updateOne(g.skillName, g.version, g.targets);
133
+ }
134
+ catch (err) {
135
+ console.warn(`[skill-logger-plugin] 自动更新 ${key} 异常`, err);
136
+ }
137
+ }
138
+ }
139
+ async updateOne(skillName, version, targets) {
140
+ const dl = await this.fetchDownloadUrl(skillName, version);
141
+ if (!dl?.url)
142
+ return;
143
+ const work = path.join(this.tmpDir, `slp-update-${randomUUID()}`);
144
+ await fs.mkdir(work, { recursive: true });
145
+ try {
146
+ const zipPath = path.join(work, "pkg.zip");
147
+ const res = await this.fetchImpl(dl.url);
148
+ if (!res.ok) {
149
+ console.warn(`[skill-logger-plugin] 下载 ${skillName}@${version} 失败 HTTP`, res.status);
150
+ return;
151
+ }
152
+ const buf = Buffer.from(await res.arrayBuffer());
153
+ await fs.writeFile(zipPath, buf);
154
+ const staging = path.join(work, "staging");
155
+ await this.unzip(zipPath, staging);
156
+ const srcRoot = await this.locateSkillRoot(staging, 0);
157
+ if (!srcRoot) {
158
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包内未找到 SKILL.md,放弃覆盖`);
159
+ return;
160
+ }
161
+ // [兼容处理] 缺少 .meta.json 时主动补齐
162
+ const metaPath = path.join(srcRoot, ".meta.json");
163
+ if (!(await this.exists(metaPath))) {
164
+ this.debug(`[skill-logger-plugin] 为 ${skillName}@${version} 自动生成 .meta.json`);
165
+ await fs.writeFile(metaPath, JSON.stringify({
166
+ ownerId: 'CMS_COMPAT',
167
+ slug: skillName,
168
+ version: version,
169
+ publishedAt: Date.now()
170
+ }, null, 2));
171
+ }
172
+ // 守卫:下载包的 SKILL.md 必须能解析出版本号,否则不做覆盖(服务端要求:无版本号不更新)。
173
+ let packageVersion;
174
+ try {
175
+ const newSkillMd = await fs.readFile(path.join(srcRoot, "SKILL.md"), "utf-8");
176
+ packageVersion = parseSkillVersion(newSkillMd);
177
+ }
178
+ catch {
179
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} 读取下载包 SKILL.md 失败,放弃覆盖`);
180
+ return;
181
+ }
182
+ if (!packageVersion) {
183
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包 SKILL.md 无版本号,放弃覆盖`);
184
+ return;
185
+ }
186
+ for (const target of targets) {
187
+ try {
188
+ await this.replaceDir(srcRoot, target);
189
+ this.debug(`已更新 ${skillName} → ${version} @ ${target}`);
190
+ }
191
+ catch (err) {
192
+ console.warn(`[skill-logger-plugin] 覆盖 ${skillName} 到 ${target} 失败`, err);
193
+ }
194
+ }
195
+ }
196
+ finally {
197
+ await fs.rm(work, { recursive: true, force: true });
198
+ }
199
+ }
200
+ /** POST 下载接口取包 URL。返回 undefined 表示拿不到(调用方放弃本次更新)。 */
201
+ async fetchDownloadUrl(skillName, version) {
202
+ const cfg = this.getConfig();
203
+ const endpoint = cfg.platformBaseUrl.replace(/\/$/, "") + "/skill_package/pull";
204
+ const headers = { "Content-Type": "application/json" };
205
+ if (cfg.authToken)
206
+ headers.Authorization = cfg.authToken;
207
+ const res = await this.fetchImpl(endpoint, {
208
+ method: "POST",
209
+ headers,
210
+ body: JSON.stringify({ skillName, version }),
211
+ });
212
+ if (!res.ok) {
213
+ console.warn(`[skill-logger-plugin] 取下载地址 ${skillName}@${version} 失败 HTTP`, res.status);
214
+ return undefined;
215
+ }
216
+ const data = (await res.json());
217
+ const url = typeof data?.url === "string" ? data.url : typeof data?.downloadUrl === "string" ? data.downloadUrl : undefined;
218
+ const resolvedVersion = typeof data?.version === "string" ? data.version : undefined;
219
+ if (!url)
220
+ return undefined;
221
+ return { url, version: resolvedVersion };
222
+ }
223
+ /** 在解压目录里定位含 SKILL.md 的目录(兼容包内是否带顶层目录)。深度上限 2。 */
224
+ async locateSkillRoot(dir, depth) {
225
+ if (depth > 2)
226
+ return undefined;
227
+ let entries;
228
+ try {
229
+ entries = await fs.readdir(dir, { withFileTypes: true });
230
+ }
231
+ catch {
232
+ return undefined;
233
+ }
234
+ if (entries.some((e) => e.isFile() && e.name === "SKILL.md"))
235
+ return dir;
236
+ for (const e of entries) {
237
+ if (e.isDirectory()) {
238
+ const found = await this.locateSkillRoot(path.join(dir, e.name), depth + 1);
239
+ if (found)
240
+ return found;
241
+ }
242
+ }
243
+ return undefined;
244
+ }
245
+ /**
246
+ * 直接覆盖、不备份,但保证「不丢数据」:
247
+ * 先把新内容暂存到同级临时目录 → 旧目录改名挪开 → 新内容 rename 换入 → 删掉挪开的旧目录。
248
+ * 换入失败时把旧目录还原,避免目标被清空。过程中的临时/旧目录都是隐藏且即时清理,不保留 .bak。
249
+ */
250
+ async replaceDir(src, target) {
251
+ const parent = path.dirname(target);
252
+ await fs.mkdir(parent, { recursive: true });
253
+ const tag = `${Date.now()}-${randomUUID().slice(0, 8)}`;
254
+ const stage = path.join(parent, `.${path.basename(target)}.new-${tag}`);
255
+ const old = path.join(parent, `.${path.basename(target)}.old-${tag}`);
256
+ await fs.rm(stage, { recursive: true, force: true });
257
+ await fs.cp(src, stage, { recursive: true });
258
+ const hadTarget = await this.exists(target);
259
+ if (hadTarget)
260
+ await fs.rename(target, old); // 旧目录先挪开(仍完整保留,便于回滚)
261
+ try {
262
+ await fs.rename(stage, target); // 新内容换入
263
+ }
264
+ catch (err) {
265
+ // 换入失败:还原旧目录,清理暂存,确保目标不丢
266
+ if (hadTarget)
267
+ await fs.rename(old, target).catch(() => { });
268
+ await fs.rm(stage, { recursive: true, force: true }).catch(() => { });
269
+ throw err;
270
+ }
271
+ await fs.rm(old, { recursive: true, force: true }).catch(() => { }); // 成功后清理,不留备份
272
+ }
273
+ async exists(p) {
274
+ try {
275
+ await fs.access(p);
276
+ return true;
277
+ }
278
+ catch {
279
+ return false;
280
+ }
281
+ }
282
+ /**
283
+ * 手动下发安装/更新指令 (供沙盒 HTTP Proxy 与星型架构中控使用)
284
+ */
285
+ async manualInstall(options) {
286
+ const { code, force, targetDir } = options;
287
+ let { url, version } = options;
288
+ if (!url) {
289
+ // 若没有直接传入 ZIP 的 URL,向平台索取该 skill 的 latest 下载地址
290
+ const dl = await this.fetchDownloadUrl(code, version || "latest");
291
+ if (!dl?.url) {
292
+ return { success: false, message: `无法获取技能 ${code} 的下载地址` };
293
+ }
294
+ url = dl.url;
295
+ version = dl.version || version;
296
+ }
297
+ const targetSkillPath = path.join(targetDir, code);
298
+ // 如果非强制覆盖,且目标已经存在,则直接返回
299
+ if (!force && await this.exists(targetSkillPath)) {
300
+ return { success: false, message: `技能 ${code} 已存在于目标目录` };
301
+ }
302
+ const work = path.join(this.tmpDir, `slp-manual-${randomUUID()}`);
303
+ await fs.mkdir(work, { recursive: true });
304
+ try {
305
+ // 1. 下载 ZIP
306
+ const zipPath = path.join(work, "pkg.zip");
307
+ const res = await this.fetchImpl(url);
308
+ if (!res.ok) {
309
+ return { success: false, message: `下载失败: HTTP ${res.status}` };
310
+ }
311
+ const buf = Buffer.from(await res.arrayBuffer());
312
+ await fs.writeFile(zipPath, buf);
313
+ // 2. 调用系统解压工具 unzip
314
+ const staging = path.join(work, "staging");
315
+ await this.unzip(zipPath, staging);
316
+ // 3. 递归寻找真正的技能根目录(包含 SKILL.md 的一层)
317
+ const srcRoot = await this.locateSkillRoot(staging, 0);
318
+ if (!srcRoot) {
319
+ return { success: false, message: `下载包内未找到 SKILL.md,非法的技能包结构` };
320
+ }
321
+ // [兼容处理] 缺少 .meta.json 时主动补齐
322
+ const metaPath = path.join(srcRoot, ".meta.json");
323
+ if (!(await this.exists(metaPath))) {
324
+ let parsedVersion = version || "unknown";
325
+ if (!version) {
326
+ try {
327
+ const newSkillMd = await fs.readFile(path.join(srcRoot, "SKILL.md"), "utf-8");
328
+ parsedVersion = parseSkillVersion(newSkillMd) || "unknown";
329
+ }
330
+ catch { }
331
+ }
332
+ this.debug(`[skill-logger-plugin] 为手动安装的 ${code}@${parsedVersion} 自动生成 .meta.json`);
333
+ await fs.writeFile(metaPath, JSON.stringify({
334
+ ownerId: 'CMS_COMPAT',
335
+ slug: code,
336
+ version: parsedVersion,
337
+ publishedAt: Date.now()
338
+ }, null, 2));
339
+ }
340
+ // 4. 原子级无缝替换目录 (先暂存,再 rename)
341
+ await this.replaceDir(srcRoot, targetSkillPath);
342
+ return { success: true, message: `技能 ${code} 安装/更新成功` };
343
+ }
344
+ catch (err) {
345
+ return { success: false, message: `执行出错: ${err.message}` };
346
+ }
347
+ finally {
348
+ // 清理临时工作区
349
+ await fs.rm(work, { recursive: true, force: true }).catch(() => { });
350
+ }
351
+ }
352
+ }
@@ -0,0 +1,212 @@
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 } from "./updater.ts";
8
+ /** 与 updater/服务端一致的身份哈希:code code version(空格分隔)。 */
9
+ const idHash = (code, version) => createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
10
+ let dir;
11
+ beforeEach(async () => {
12
+ dir = path.join(os.tmpdir(), `slp-updater-${Date.now()}-${Math.random().toString(36).slice(2)}`);
13
+ await fs.mkdir(dir, { recursive: true });
14
+ });
15
+ afterEach(async () => {
16
+ await fs.rm(dir, { recursive: true, force: true });
17
+ });
18
+ /** 注入的假 fetch:POST 取下载地址返回 {url},GET 下载返回固定 zip 字节。 */
19
+ function makeFetch(opts = {}) {
20
+ const calls = opts.calls ?? [];
21
+ return async (url, init) => {
22
+ calls.push(url);
23
+ if (init?.method === "POST") {
24
+ return {
25
+ ok: opts.urlOk !== false,
26
+ status: opts.urlOk !== false ? 200 : 500,
27
+ json: async () => ({ url: "https://pkg.example/skill.zip" }),
28
+ arrayBuffer: async () => new ArrayBuffer(0),
29
+ };
30
+ }
31
+ // GET 下载
32
+ return {
33
+ ok: opts.downloadOk !== false,
34
+ status: opts.downloadOk !== false ? 200 : 500,
35
+ json: async () => ({}),
36
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
37
+ };
38
+ };
39
+ }
40
+ /** 注入的假 unzip:把「新版本」内容写进 staging(含 SKILL.md),模拟解压结果。 */
41
+ function makeUnzip(newVersion, withSkillMd = true) {
42
+ return async (_zipPath, destDir) => {
43
+ const root = path.join(destDir, "skill-root");
44
+ await fs.mkdir(root, { recursive: true });
45
+ if (withSkillMd) {
46
+ await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${newVersion}\n---\nNEW\n`);
47
+ await fs.writeFile(path.join(root, "new-file.txt"), "added by update");
48
+ }
49
+ else {
50
+ await fs.writeFile(path.join(root, "note.txt"), "no skill md here");
51
+ }
52
+ };
53
+ }
54
+ async function makeTarget(name) {
55
+ const target = path.join(dir, "workspace", "skills", name);
56
+ await fs.mkdir(target, { recursive: true });
57
+ await fs.writeFile(path.join(target, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
58
+ await fs.writeFile(path.join(target, "old-file.txt"), "should be gone after update");
59
+ return target;
60
+ }
61
+ const outdatedFor = (target) => [
62
+ { skillName: "demo", rootDir: target, localVersion: "1.0.0", latestVersion: "2.0.0" },
63
+ ];
64
+ describe("SkillUpdater", () => {
65
+ it("autoUpdateSkills 显式 false:不发请求、不动文件", async () => {
66
+ const target = await makeTarget("demo");
67
+ const calls = [];
68
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: false };
69
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
70
+ await u.applyUpdates(outdatedFor(target));
71
+ assert.equal(calls.length, 0);
72
+ assert.equal((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), true);
73
+ });
74
+ it("autoUpdateSkills 未设置(默认开启):执行更新", async () => {
75
+ const target = await makeTarget("demo");
76
+ const cfg = { platformBaseUrl: "https://api" }; // 不设 autoUpdateSkills → 默认开
77
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
78
+ await u.applyUpdates(outdatedFor(target));
79
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("NEW"), "默认应执行更新");
80
+ });
81
+ it("未配置 platformBaseUrl:不更新", async () => {
82
+ const target = await makeTarget("demo");
83
+ const cfg = {}; // 无平台地址
84
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
85
+ await u.applyUpdates(outdatedFor(target));
86
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"));
87
+ });
88
+ it("开启后:下载→解压→覆盖目标目录(直接覆盖,旧文件消失,无 .bak)", async () => {
89
+ const target = await makeTarget("demo");
90
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
91
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0"), tmpDir: dir });
92
+ await u.applyUpdates(outdatedFor(target));
93
+ const skillMd = await fs.readFile(path.join(target, "SKILL.md"), "utf-8");
94
+ assert.ok(skillMd.includes("NEW"), "SKILL.md 应被更新为新内容");
95
+ assert.ok(skillMd.includes("version: 2.0.0"), "版本应升级");
96
+ assert.ok(await fileExists(path.join(target, "new-file.txt")), "新增文件应出现");
97
+ assert.equal(await fileExists(path.join(target, "old-file.txt")), false, "旧文件应被覆盖移除");
98
+ // 不留备份
99
+ const siblings = await fs.readdir(path.join(dir, "workspace", "skills"));
100
+ assert.ok(!siblings.some((f) => f.includes(".bak")), "不应留下 .bak 备份");
101
+ });
102
+ it("一个 skill 装在多个 workspace:仅下载一次,覆盖全部副本", async () => {
103
+ const t1 = await makeTarget("demo");
104
+ const t2Dir = path.join(dir, "workspace-coder", "skills", "demo");
105
+ await fs.mkdir(t2Dir, { recursive: true });
106
+ await fs.writeFile(path.join(t2Dir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD2\n");
107
+ const calls = [];
108
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
109
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
110
+ await u.applyUpdates([
111
+ { skillName: "demo", rootDir: t1, localVersion: "1.0.0", latestVersion: "2.0.0" },
112
+ { skillName: "demo", rootDir: t2Dir, localVersion: "1.0.0", latestVersion: "2.0.0" },
113
+ ]);
114
+ const postCalls = calls.filter((c) => c.endsWith("/skill_package/pull"));
115
+ assert.equal(postCalls.length, 1, "同 skill@version 只取一次下载地址");
116
+ assert.ok((await fs.readFile(path.join(t1, "SKILL.md"), "utf-8")).includes("NEW"));
117
+ assert.ok((await fs.readFile(path.join(t2Dir, "SKILL.md"), "utf-8")).includes("NEW"));
118
+ });
119
+ it("下载包 SKILL.md 无版本号:放弃覆盖(服务端要求无版本不更新)", async () => {
120
+ const target = await makeTarget("demo");
121
+ // 解压出的 SKILL.md 不含版本号
122
+ const unzipNoVersion = async (_z, destDir) => {
123
+ const root = path.join(destDir, "skill-root");
124
+ await fs.mkdir(root, { recursive: true });
125
+ await fs.writeFile(path.join(root, "SKILL.md"), "---\nname: demo\n---\nNEW but no version\n");
126
+ };
127
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
128
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: unzipNoVersion, tmpDir: dir });
129
+ await u.applyUpdates(outdatedFor(target));
130
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "无版本号时应保持原样");
131
+ });
132
+ /** 构造一个 POST 返回指定 sha256 的 fetch。 */
133
+ const makeFetchWithHash = (sha256) => async (url, init) => {
134
+ if (init?.method === "POST") {
135
+ return {
136
+ ok: true,
137
+ status: 200,
138
+ json: async () => ({ url: "https://pkg.example/skill.zip", sha256 }),
139
+ arrayBuffer: async () => new ArrayBuffer(0),
140
+ };
141
+ }
142
+ return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
143
+ };
144
+ it("身份哈希一致:正常覆盖", async () => {
145
+ const target = await makeTarget("demo");
146
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
147
+ // 服务端按 demo + 包内版本 2.0.0 计算哈希;包内 SKILL.md 也是 2.0.0 → 一致
148
+ const u = new SkillUpdater({
149
+ getConfig: () => cfg,
150
+ fetchImpl: makeFetchWithHash(idHash("demo", "2.0.0")),
151
+ unzip: makeUnzip("2.0.0"),
152
+ tmpDir: dir,
153
+ });
154
+ await u.applyUpdates(outdatedFor(target));
155
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("NEW"), "哈希一致应覆盖");
156
+ });
157
+ it("身份哈希不一致:放弃覆盖", async () => {
158
+ const target = await makeTarget("demo");
159
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
160
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetchWithHash("deadbeef"), unzip: makeUnzip("2.0.0"), tmpDir: dir });
161
+ await u.applyUpdates(outdatedFor(target));
162
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "校验失败应保持原样");
163
+ });
164
+ it("包内版本与服务端声称不符(哈希对不上):放弃覆盖", async () => {
165
+ const target = await makeTarget("demo");
166
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
167
+ // 服务端按声称的最新版本 2.0.0 算哈希,但下载包实际是 9.9.9 → 包内版本算出的哈希对不上
168
+ const u = new SkillUpdater({
169
+ getConfig: () => cfg,
170
+ fetchImpl: makeFetchWithHash(idHash("demo", "2.0.0")),
171
+ unzip: makeUnzip("9.9.9"),
172
+ tmpDir: dir,
173
+ });
174
+ await u.applyUpdates(outdatedFor(target));
175
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "版本错配应放弃覆盖");
176
+ });
177
+ it("下载包内无 SKILL.md:放弃覆盖,目标目录保持原样", async () => {
178
+ const target = await makeTarget("demo");
179
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
180
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch(), unzip: makeUnzip("2.0.0", false), tmpDir: dir });
181
+ await u.applyUpdates(outdatedFor(target));
182
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"), "目标应保持旧内容");
183
+ assert.ok(await fileExists(path.join(target, "old-file.txt")), "目标旧文件应仍在");
184
+ });
185
+ it("冷却:同 skill@version 失败后短期内不重复尝试", async () => {
186
+ const target = await makeTarget("demo");
187
+ const calls = [];
188
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
189
+ // 取下载地址一直失败 → 每次尝试都不会成功
190
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ urlOk: false, calls }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
191
+ await u.applyUpdates(outdatedFor(target));
192
+ await u.applyUpdates(outdatedFor(target)); // 第二轮应被冷却拦下
193
+ const postCalls = calls.filter((c) => c.endsWith("/skill_package/pull"));
194
+ assert.equal(postCalls.length, 1, "冷却期内不应重复发起下载");
195
+ });
196
+ it("取下载地址失败:不动目标目录", async () => {
197
+ const target = await makeTarget("demo");
198
+ const cfg = { platformBaseUrl: "https://api", autoUpdateSkills: true };
199
+ const u = new SkillUpdater({ getConfig: () => cfg, fetchImpl: makeFetch({ urlOk: false }), unzip: makeUnzip("2.0.0"), tmpDir: dir });
200
+ await u.applyUpdates(outdatedFor(target));
201
+ assert.ok((await fs.readFile(path.join(target, "SKILL.md"), "utf-8")).includes("OLD"));
202
+ });
203
+ });
204
+ async function fileExists(p) {
205
+ try {
206
+ await fs.access(p);
207
+ return true;
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ }