@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
package/src/updater.ts CHANGED
@@ -1,433 +1,549 @@
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 fsSync from "node:fs";
20
- import path from "node:path";
21
- import os from "node:os";
22
- import { execFile } from "node:child_process";
23
- import { promisify } from "node:util";
24
- import { randomUUID, createHash } from "node:crypto";
25
- import type { PluginConfig } from "./types.ts";
26
- import { parseSkillVersion, readSkillVersion } from "./skill-version.ts";
27
-
28
- const execFileAsync = promisify(execFile);
29
-
30
- /**
31
- * 同一 skill@version 的更新尝试冷却期。
32
- * 防止「下载包版本号与平台 latestVersion 不一致 / 非 semver 版本永不相等」等情况下,
33
- * 每 3 分钟一轮 reconcile 反复下载覆盖。成功更新会改变本地版本从而不再判过期,故冷却只影响失败/不一致场景。
34
- */
35
- const ATTEMPT_COOLDOWN_MS = 30 * 60 * 1000;
36
-
37
- /**
38
- * 轻量「身份哈希」:name + code + version。约定 name==code(SKILL.md name 即 skillCode),
39
- * 故按 `code code version`(空格分隔)计算 sha256。与服务端 skillIdentityHash 必须保持一致。
40
- * 仅做身份/版本一致性校验,不做整包字节哈希。
41
- */
42
- function skillIdentityHash(code: string, version: string): string {
43
- return createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
44
- }
45
-
46
- /** 一个落后的安装副本(detectOutdated 的产物)。 */
47
- export type OutdatedCopy = {
48
- skillName: string;
49
- rootDir: string;
50
- localVersion: string;
51
- latestVersion: string;
52
- };
53
-
54
- type DownloadFetch = (
55
- url: string,
56
- init?: RequestInit
57
- ) => Promise<{ ok: boolean; status: number; json: () => Promise<any>; arrayBuffer: () => Promise<ArrayBuffer> }>;
58
-
59
- /** 解压函数:把 zipPath 解到 destDir。可注入便于测试。 */
60
- export type UnzipFn = (zipPath: string, destDir: string) => Promise<void>;
61
-
62
- export type UpdaterOptions = {
63
- getConfig: () => PluginConfig;
64
- fetchImpl?: DownloadFetch;
65
- unzip?: UnzipFn;
66
- /** 临时工作根目录,便于测试注入;默认 os.tmpdir()。 */
67
- tmpDir?: string;
68
- /** 冷却期状态持久化文件路径。 */
69
- cooldownStatePath?: string;
70
- };
71
-
72
- /** 默认解压:系统 unzip -o -q(覆盖、静默)。 */
73
- async function systemUnzip(zipPath: string, destDir: string): Promise<void> {
74
- await fs.mkdir(destDir, { recursive: true });
75
- await execFileAsync("unzip", ["-o", "-q", zipPath, "-d", destDir]);
76
- }
77
-
78
- export class SkillUpdater {
79
- private readonly getConfig: () => PluginConfig;
80
- private readonly fetchImpl: DownloadFetch;
81
- private readonly unzip: UnzipFn;
82
- private readonly tmpDir: string;
83
- /** 冷却期状态持久化文件路径。 */
84
- private readonly cooldownStatePath?: string;
85
- /** skill@version 上次尝试时间戳,用于冷却,避免重复下载覆盖。 */
86
- private readonly lastAttempt = new Map<string, number>();
87
-
88
- constructor(opts: UpdaterOptions) {
89
- this.getConfig = opts.getConfig;
90
- this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as unknown as DownloadFetch);
91
- this.unzip = opts.unzip ?? systemUnzip;
92
- this.tmpDir = opts.tmpDir ?? os.tmpdir();
93
- this.cooldownStatePath = opts.cooldownStatePath;
94
- this.loadCooldown(); // 同步读取,启动早期就需要用到冷却期
95
- }
96
-
97
- private get isDebug(): boolean {
98
- return this.getConfig().debugLogging !== false;
99
- }
100
-
101
- private debug(...args: any[]): void {
102
- if (this.isDebug) console.log("[skill-logger-plugin/updater]", ...args);
103
- }
104
-
105
- /** 从文件同步加载冷却期状态到内存Map。文件不存在或解析失败时静默跳过。 */
106
- private loadCooldown(): void {
107
- if (!this.cooldownStatePath) return;
108
- try {
109
- const raw = fsSync.readFileSync(this.cooldownStatePath, "utf-8");
110
- const obj = JSON.parse(raw) as Record<string, number>;
111
- for (const [k, v] of Object.entries(obj)) {
112
- if (typeof v === "number" && Number.isFinite(v)) {
113
- this.lastAttempt.set(k, v);
114
- }
115
- }
116
- this.debug(`冷却期状态已从文件恢复,共 ${this.lastAttempt.size} 条`);
117
- } catch {
118
- // 文件不存在或解析失败 用空Map,插件继续正常工作
119
- }
120
- }
121
-
122
- /** 将内存中的冷却期状态写入文件。写入失败时静默跳过,不影响主流程。 */
123
- private async persistCooldown(): Promise<void> {
124
- if (!this.cooldownStatePath) return;
125
- try {
126
- const obj: Record<string, number> = {};
127
- for (const [k, v] of this.lastAttempt) {
128
- obj[k] = v;
129
- }
130
- await fs.writeFile(this.cooldownStatePath, JSON.stringify(obj), "utf-8");
131
- } catch {
132
- // 写入失败不影响主流程,下次会继续尝试
133
- }
134
- }
135
-
136
- /** 对一批落后副本执行更新。按 skill@version 去重下载,覆盖各自的全部目标目录。 */
137
- async applyUpdates(outdated: OutdatedCopy[]): Promise<void> {
138
- const cfg = this.getConfig();
139
- if (cfg.autoUpdateSkills === false) return; // 默认开启,仅显式 false 关闭
140
- if (!cfg.platformBaseUrl) return; // 无平台地址无法下载
141
- if (outdated.length === 0) return;
142
-
143
- const groups = new Map<string, { skillName: string; version: string; targets: string[] }>();
144
- for (const o of outdated) {
145
- const key = `${o.skillName}@${o.latestVersion}`;
146
- let g = groups.get(key);
147
- if (!g) {
148
- g = { skillName: o.skillName, version: o.latestVersion, targets: [] };
149
- groups.set(key, g);
150
- }
151
- if (!g.targets.includes(o.rootDir)) g.targets.push(o.rootDir);
152
- }
153
-
154
- for (const g of groups.values()) {
155
- const key = `${g.skillName}@${g.version}`;
156
- const last = this.lastAttempt.get(key);
157
- if (last !== undefined && Date.now() - last < ATTEMPT_COOLDOWN_MS) {
158
- this.debug(`跳过 ${key}:距上次尝试不足冷却期(防重复下载覆盖)`);
159
- continue;
160
- }
161
- this.lastAttempt.set(key, Date.now()); // 不论成败都记,失败也按冷却退避,避免每 3 分钟重试
162
- await this.persistCooldown(); // 持久化到文件,重启后冷却期不丢失
163
- try {
164
- await this.updateOne(g.skillName, g.version, g.targets);
165
- } catch (err) {
166
- console.warn(`[skill-logger-plugin] 自动更新 ${key} 异常`, err);
167
- }
168
- }
169
- }
170
-
171
- private async updateOne(skillName: string, version: string, targets: string[]): Promise<void> {
172
- const dl = await this.fetchDownloadUrl(skillName, version);
173
- if (!dl?.url) return;
174
-
175
- const work = path.join(this.tmpDir, `slp-update-${randomUUID()}`);
176
- await fs.mkdir(work, { recursive: true });
177
- try {
178
- const zipPath = path.join(work, "pkg.zip");
179
- const res = await this.fetchImpl(dl.url);
180
- if (!res.ok) {
181
- console.warn(`[skill-logger-plugin] 下载 ${skillName}@${version} 失败 HTTP`, res.status);
182
- return;
183
- }
184
- const buf = Buffer.from(await res.arrayBuffer());
185
- await fs.writeFile(zipPath, buf);
186
-
187
- const staging = path.join(work, "staging");
188
- await this.unzip(zipPath, staging);
189
-
190
- const srcRoot = await this.locateSkillRoot(staging, 0);
191
- if (!srcRoot) {
192
- console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包内未找到 SKILL.md,放弃覆盖`);
193
- return;
194
- }
195
-
196
- // 守卫:下载包必须能解析出版本号,否则不做覆盖(服务端要求:无版本号不更新)。
197
- let packageVersion: string | undefined;
198
- try {
199
- packageVersion = await readSkillVersion(srcRoot);
200
- } catch (err) {
201
- console.warn(`[skill-logger-plugin] ${skillName}@${version} 读取下载包版本号失败,放弃覆盖`, err);
202
- return;
203
- }
204
- if (!packageVersion) {
205
- console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包无版本号,放弃覆盖`);
206
- return;
207
- }
208
- if (dl.sha256) {
209
- const actualHash = skillIdentityHash(skillName, packageVersion);
210
- if (actualHash !== dl.sha256) {
211
- console.warn(`[skill-logger-plugin] ${skillName}@${version} 身份哈希不一致,放弃覆盖`);
212
- return;
213
- }
214
- }
215
-
216
- // [兼容处理] 缺少 .meta.json 时主动补齐
217
- const metaPath = path.join(srcRoot, ".meta.json");
218
- if (!(await this.exists(metaPath))) {
219
- this.debug(`[skill-logger-plugin] 为 ${skillName}@${version} 自动生成 .meta.json`);
220
- await fs.writeFile(metaPath, JSON.stringify({
221
- ownerId: 'CMS_COMPAT',
222
- slug: skillName,
223
- version: packageVersion,
224
- publishedAt: Date.now()
225
- }, null, 2));
226
- }
227
-
228
- for (const target of targets) {
229
- try {
230
- await this.replaceDir(srcRoot, target);
231
- this.debug(`已更新 ${skillName} → ${version} @ ${target}`);
232
- } catch (err) {
233
- console.warn(`[skill-logger-plugin] 覆盖 ${skillName} 到 ${target} 失败`, err);
234
- }
235
- }
236
- } finally {
237
- await fs.rm(work, { recursive: true, force: true });
238
- }
239
- }
240
-
241
- /** POST 下载接口取包 URL。返回 undefined 表示拿不到(调用方放弃本次更新)。 */
242
- private async fetchDownloadUrl(
243
- skillName: string,
244
- version: string
245
- ): Promise<{ url: string; version?: string; sha256?: string } | undefined> {
246
- const cfg = this.getConfig();
247
- const endpoint = cfg.platformBaseUrl!.replace(/\/$/, "") + "/skill_package/pull";
248
- const headers: Record<string, string> = { "Content-Type": "application/json" };
249
- if (cfg.authToken) headers.Authorization = cfg.authToken;
250
- const res = await this.fetchImpl(endpoint, {
251
- method: "POST",
252
- headers,
253
- body: JSON.stringify({ skillName, version }),
254
- });
255
- if (!res.ok) {
256
- console.warn(`[skill-logger-plugin] 取下载地址 ${skillName}@${version} 失败 HTTP`, res.status);
257
- return undefined;
258
- }
259
- const data = (await res.json()) as { url?: unknown; downloadUrl?: unknown; version?: unknown; sha256?: unknown };
260
- const url = typeof data?.url === "string" ? data.url : typeof data?.downloadUrl === "string" ? data.downloadUrl : undefined;
261
- const resolvedVersion = typeof data?.version === "string" ? data.version : undefined;
262
- const sha256 = typeof data?.sha256 === "string" ? data.sha256 : undefined;
263
- if (!url) return undefined;
264
- return { url, version: resolvedVersion, sha256 };
265
- }
266
-
267
- /** 在解压目录里定位含 SKILL.md 的目录(兼容包内是否带顶层目录)。深度上限 2。 */
268
- private async locateSkillRoot(dir: string, depth: number): Promise<string | undefined> {
269
- if (depth > 2) return undefined;
270
- let entries;
271
- try {
272
- entries = await fs.readdir(dir, { withFileTypes: true });
273
- } catch {
274
- return undefined;
275
- }
276
- if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) return dir;
277
- for (const e of entries) {
278
- if (e.isDirectory()) {
279
- const found = await this.locateSkillRoot(path.join(dir, e.name), depth + 1);
280
- if (found) return found;
281
- }
282
- }
283
- return undefined;
284
- }
285
-
286
- /**
287
- * 直接覆盖、不备份,但保证「不丢数据」:
288
- * 先把新内容暂存到同级临时目录 旧目录改名挪开 新内容 rename 换入 → 删掉挪开的旧目录。
289
- * 换入失败时把旧目录还原,避免目标被清空。过程中的临时/旧目录都是隐藏且即时清理,不保留 .bak。
290
- */
291
- private async replaceDir(src: string, target: string): Promise<void> {
292
- const parent = path.dirname(target);
293
- await fs.mkdir(parent, { recursive: true });
294
- const tag = `${Date.now()}-${randomUUID().slice(0, 8)}`;
295
- const stage = path.join(parent, `.${path.basename(target)}.new-${tag}`);
296
- const old = path.join(parent, `.${path.basename(target)}.old-${tag}`);
297
-
298
- await fs.rm(stage, { recursive: true, force: true });
299
- await fs.cp(src, stage, { recursive: true });
300
-
301
- const hadTarget = await this.exists(target);
302
- if (hadTarget) await fs.rename(target, old); // 旧目录先挪开(仍完整保留,便于回滚)
303
- try {
304
- await fs.rename(stage, target); // 新内容换入
305
- } catch (err) {
306
- // 换入失败:还原旧目录,清理暂存,确保目标不丢
307
- if (hadTarget) await fs.rename(old, target).catch(() => {});
308
- await fs.rm(stage, { recursive: true, force: true }).catch(() => {});
309
- throw err;
310
- }
311
- await fs.rm(old, { recursive: true, force: true }).catch(() => {}); // 成功后清理,不留备份
312
- }
313
-
314
- private async exists(p: string): Promise<boolean> {
315
- try {
316
- await fs.access(p);
317
- return true;
318
- } catch {
319
- return false;
320
- }
321
- }
322
-
323
- /**
324
- * 手动下发安装/更新指令 (供沙盒 HTTP Proxy 与星型架构中控使用)
325
- */
326
- /**
327
- * URL 下载 ZIP 并直接解压替换到目标目录(不检查 SKILL.md)
328
- */
329
- async installZipFromUrl(url: string, targetDir: string): Promise<{success: boolean; message: string}> {
330
- const work = path.join(this.tmpDir, `slp-zip-${randomUUID()}`);
331
- await fs.mkdir(work, { recursive: true });
332
- try {
333
- const zipPath = path.join(work, "pkg.zip");
334
- const res = await this.fetchImpl(url);
335
- if (!res.ok) {
336
- return { success: false, message: `下载失败: HTTP ${res.status}` };
337
- }
338
- const buf = Buffer.from(await res.arrayBuffer());
339
- await fs.writeFile(zipPath, buf);
340
-
341
- const staging = path.join(work, "staging");
342
- await this.unzip(zipPath, staging);
343
-
344
- await this.replaceDir(staging, targetDir);
345
- return { success: true, message: `安装成功` };
346
- } catch (err: any) {
347
- return { success: false, message: `执行出错: ${err.message}` };
348
- } finally {
349
- await fs.rm(work, { recursive: true, force: true }).catch(() => {});
350
- }
351
- }
352
-
353
- async manualInstall(options: {
354
- code: string;
355
- url?: string;
356
- version?: string;
357
- force: boolean;
358
- targetDir: string;
359
- }): Promise<{ success: boolean; message: string }> {
360
- const { code, force, targetDir } = options;
361
- let { url, version } = options;
362
-
363
- if (!url) {
364
- // 若没有直接传入 ZIP 的 URL,向平台索取该 skill 的 latest 下载地址
365
- const dl = await this.fetchDownloadUrl(code, version || "latest");
366
- if (!dl?.url) {
367
- return { success: false, message: `无法获取技能 ${code} 的下载地址` };
368
- }
369
- url = dl.url;
370
- version = dl.version || version;
371
- }
372
-
373
- const targetSkillPath = path.join(targetDir, code);
374
-
375
- // 如果非强制覆盖,且目标已经存在,则直接返回
376
- if (!force && await this.exists(targetSkillPath)) {
377
- return { success: false, message: `技能 ${code} 已存在于目标目录` };
378
- }
379
-
380
- const work = path.join(this.tmpDir, `slp-manual-${randomUUID()}`);
381
- await fs.mkdir(work, { recursive: true });
382
-
383
- try {
384
- // 1. 下载 ZIP
385
- const zipPath = path.join(work, "pkg.zip");
386
- const res = await this.fetchImpl(url);
387
- if (!res.ok) {
388
- return { success: false, message: `下载失败: HTTP ${res.status}` };
389
- }
390
- const buf = Buffer.from(await res.arrayBuffer());
391
- await fs.writeFile(zipPath, buf);
392
-
393
- // 2. 调用系统解压工具 unzip
394
- const staging = path.join(work, "staging");
395
- await this.unzip(zipPath, staging);
396
-
397
- // 3. 递归寻找真正的技能根目录(包含 SKILL.md 的一层)
398
- const srcRoot = await this.locateSkillRoot(staging, 0);
399
- if (!srcRoot) {
400
- return { success: false, message: `下载包内未找到 SKILL.md,非法的技能包结构` };
401
- }
402
-
403
- // [兼容处理] 缺少 .meta.json 时主动补齐
404
- const metaPath = path.join(srcRoot, ".meta.json");
405
- if (!(await this.exists(metaPath))) {
406
- let parsedVersion = version || "unknown";
407
- if (!version) {
408
- try {
409
- parsedVersion = (await readSkillVersion(srcRoot)) || "unknown";
410
- } catch {}
411
- }
412
-
413
- this.debug(`[skill-logger-plugin] 为手动安装的 ${code}@${parsedVersion} 自动生成 .meta.json`);
414
- await fs.writeFile(metaPath, JSON.stringify({
415
- ownerId: 'CMS_COMPAT',
416
- slug: code,
417
- version: parsedVersion,
418
- publishedAt: Date.now()
419
- }, null, 2));
420
- }
421
-
422
- // 4. 原子级无缝替换目录 (先暂存,再 rename)
423
- await this.replaceDir(srcRoot, targetSkillPath);
424
-
425
- return { success: true, message: `技能 ${code} 安装/更新成功` };
426
- } catch (err: any) {
427
- return { success: false, message: `执行出错: ${err.message}` };
428
- } finally {
429
- // 清理临时工作区
430
- await fs.rm(work, { recursive: true, force: true }).catch(()=>{});
431
- }
432
- }
433
- }
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 目录(macOS 使用 ditto,其他系统使用 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 fsSync from "node:fs";
20
+ import path from "node:path";
21
+ import os from "node:os";
22
+ import { execFile } from "node:child_process";
23
+ import { randomUUID, createHash } from "node:crypto";
24
+ import type { PluginConfig } from "./types.ts";
25
+ import { parseSkillVersion, readSkillVersion } from "./skill-version.ts";
26
+
27
+ const EXTRACT_TIMEOUT_MS = 30_000;
28
+
29
+ /**
30
+ * 同一 skill@version 的更新尝试冷却期。
31
+ * 防止「下载包版本号与平台 latestVersion 不一致 / 非 semver 版本永不相等」等情况下,
32
+ * 3 分钟一轮 reconcile 反复下载覆盖。成功更新会改变本地版本从而不再判过期,故冷却只影响失败/不一致场景。
33
+ */
34
+ const ATTEMPT_COOLDOWN_MS = 30 * 60 * 1000;
35
+
36
+ /**
37
+ * 轻量「身份哈希」:name + code + version。约定 name==code(SKILL.md 的 name 即 skillCode),
38
+ * 故按 `code code version`(空格分隔)计算 sha256。与服务端 skillIdentityHash 必须保持一致。
39
+ * 仅做身份/版本一致性校验,不做整包字节哈希。
40
+ */
41
+ function skillIdentityHash(code: string, version: string): string {
42
+ return createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
43
+ }
44
+
45
+ /** 一个落后的安装副本(detectOutdated 的产物)。 */
46
+ export type OutdatedCopy = {
47
+ skillName: string;
48
+ rootDir: string;
49
+ localVersion: string;
50
+ latestVersion: string;
51
+ };
52
+
53
+ type DownloadFetch = (
54
+ url: string,
55
+ init?: RequestInit
56
+ ) => Promise<{
57
+ ok: boolean;
58
+ status: number;
59
+ headers?: { get: (name: string) => string | null };
60
+ json: () => Promise<any>;
61
+ arrayBuffer: () => Promise<ArrayBuffer>;
62
+ }>;
63
+
64
+ export type InstallTrace = (stage: string, data?: Record<string, unknown>) => void;
65
+
66
+ /** 解压函数:把 zipPath 解到 destDir。可注入便于测试。 */
67
+ export type UnzipFn = (zipPath: string, destDir: string) => Promise<void>;
68
+
69
+ export type UpdaterOptions = {
70
+ getConfig: () => PluginConfig;
71
+ fetchImpl?: DownloadFetch;
72
+ unzip?: UnzipFn;
73
+ /** 临时工作根目录,便于测试注入;默认 os.tmpdir() */
74
+ tmpDir?: string;
75
+ /** 冷却期状态持久化文件路径。 */
76
+ cooldownStatePath?: string;
77
+ };
78
+
79
+ export function extractorCommandForPlatform(
80
+ platform: NodeJS.Platform,
81
+ zipPath: string,
82
+ destDir: string,
83
+ ): { command: string; args: string[] } {
84
+ return platform === "darwin"
85
+ ? { command: "ditto", args: ["-x", "-k", zipPath, destDir] }
86
+ : { command: "unzip", args: ["-o", "-q", zipPath, "-d", destDir] };
87
+ }
88
+
89
+ function runExtractor(command: string, args: string[]): Promise<void> {
90
+ return new Promise((resolve, reject) => {
91
+ const child = execFile(command, args, {
92
+ encoding: "utf8",
93
+ timeout: EXTRACT_TIMEOUT_MS,
94
+ killSignal: "SIGKILL",
95
+ maxBuffer: 1024 * 1024,
96
+ }, (error, _stdout, stderr) => {
97
+ if (!error) {
98
+ resolve();
99
+ return;
100
+ }
101
+ const detail = String(stderr || "").trim();
102
+ const timeout = error.killed ? `,超过 ${EXTRACT_TIMEOUT_MS}ms 已终止` : "";
103
+ reject(new Error(`${command} 解压失败${timeout}: ${detail || error.message}`));
104
+ });
105
+ // 防止 unzip 遇到异常包时弹出 Continue? 并永久等待输入。
106
+ child.stdin?.end();
107
+ });
108
+ }
109
+
110
+ /** 默认解压:macOS ditto 正确支持 ZIP 中文文件名;其余平台保持原 unzip 行为。 */
111
+ async function systemUnzip(zipPath: string, destDir: string): Promise<void> {
112
+ await fs.mkdir(destDir, { recursive: true });
113
+ const { command, args } = extractorCommandForPlatform(process.platform, zipPath, destDir);
114
+ await runExtractor(command, args);
115
+ }
116
+
117
+ export class SkillUpdater {
118
+ private readonly getConfig: () => PluginConfig;
119
+ private readonly fetchImpl: DownloadFetch;
120
+ private readonly unzip: UnzipFn;
121
+ private readonly tmpDir: string;
122
+ /** 冷却期状态持久化文件路径。 */
123
+ private readonly cooldownStatePath?: string;
124
+ /** skill@version → 上次尝试时间戳,用于冷却,避免重复下载覆盖。 */
125
+ private readonly lastAttempt = new Map<string, number>();
126
+
127
+ constructor(opts: UpdaterOptions) {
128
+ this.getConfig = opts.getConfig;
129
+ this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as unknown as DownloadFetch);
130
+ this.unzip = opts.unzip ?? systemUnzip;
131
+ this.tmpDir = opts.tmpDir ?? os.tmpdir();
132
+ this.cooldownStatePath = opts.cooldownStatePath;
133
+ this.loadCooldown(); // 同步读取,启动早期就需要用到冷却期
134
+ }
135
+
136
+ private get isDebug(): boolean {
137
+ return this.getConfig().debugLogging !== false;
138
+ }
139
+
140
+ private debug(...args: any[]): void {
141
+ if (this.isDebug) console.log("[skill-logger-plugin/updater]", ...args);
142
+ }
143
+
144
+ /** 从文件同步加载冷却期状态到内存Map。文件不存在或解析失败时静默跳过。 */
145
+ private loadCooldown(): void {
146
+ if (!this.cooldownStatePath) return;
147
+ try {
148
+ const raw = fsSync.readFileSync(this.cooldownStatePath, "utf-8");
149
+ const obj = JSON.parse(raw) as Record<string, number>;
150
+ for (const [k, v] of Object.entries(obj)) {
151
+ if (typeof v === "number" && Number.isFinite(v)) {
152
+ this.lastAttempt.set(k, v);
153
+ }
154
+ }
155
+ this.debug(`冷却期状态已从文件恢复,共 ${this.lastAttempt.size} 条`);
156
+ } catch {
157
+ // 文件不存在或解析失败 用空Map,插件继续正常工作
158
+ }
159
+ }
160
+
161
+ /** 将内存中的冷却期状态写入文件。写入失败时静默跳过,不影响主流程。 */
162
+ private async persistCooldown(): Promise<void> {
163
+ if (!this.cooldownStatePath) return;
164
+ try {
165
+ const obj: Record<string, number> = {};
166
+ for (const [k, v] of this.lastAttempt) {
167
+ obj[k] = v;
168
+ }
169
+ await fs.writeFile(this.cooldownStatePath, JSON.stringify(obj), "utf-8");
170
+ } catch {
171
+ // 写入失败不影响主流程,下次会继续尝试
172
+ }
173
+ }
174
+
175
+ /** 对一批落后副本执行更新。按 skill@version 去重下载,覆盖各自的全部目标目录。 */
176
+ async applyUpdates(outdated: OutdatedCopy[]): Promise<void> {
177
+ const cfg = this.getConfig();
178
+ if (cfg.autoUpdateSkills === false) return; // 默认开启,仅显式 false 关闭
179
+ if (!cfg.platformBaseUrl) return; // 无平台地址无法下载
180
+ if (outdated.length === 0) return;
181
+
182
+ const groups = new Map<string, { skillName: string; version: string; targets: string[] }>();
183
+ for (const o of outdated) {
184
+ const key = `${o.skillName}@${o.latestVersion}`;
185
+ let g = groups.get(key);
186
+ if (!g) {
187
+ g = { skillName: o.skillName, version: o.latestVersion, targets: [] };
188
+ groups.set(key, g);
189
+ }
190
+ if (!g.targets.includes(o.rootDir)) g.targets.push(o.rootDir);
191
+ }
192
+
193
+ for (const g of groups.values()) {
194
+ const key = `${g.skillName}@${g.version}`;
195
+ const last = this.lastAttempt.get(key);
196
+ if (last !== undefined && Date.now() - last < ATTEMPT_COOLDOWN_MS) {
197
+ this.debug(`跳过 ${key}:距上次尝试不足冷却期(防重复下载覆盖)`);
198
+ continue;
199
+ }
200
+ this.lastAttempt.set(key, Date.now()); // 不论成败都记,失败也按冷却退避,避免每 3 分钟重试
201
+ await this.persistCooldown(); // 持久化到文件,重启后冷却期不丢失
202
+ try {
203
+ await this.updateOne(g.skillName, g.version, g.targets);
204
+ } catch (err) {
205
+ console.warn(`[skill-logger-plugin] 自动更新 ${key} 异常`, err);
206
+ }
207
+ }
208
+ }
209
+
210
+ private async updateOne(skillName: string, version: string, targets: string[]): Promise<void> {
211
+ const dl = await this.fetchDownloadUrl(skillName, version);
212
+ if (!dl?.url) return;
213
+
214
+ const work = path.join(this.tmpDir, `slp-update-${randomUUID()}`);
215
+ await fs.mkdir(work, { recursive: true });
216
+ try {
217
+ const zipPath = path.join(work, "pkg.zip");
218
+ const res = await this.fetchImpl(dl.url);
219
+ if (!res.ok) {
220
+ console.warn(`[skill-logger-plugin] 下载 ${skillName}@${version} 失败 HTTP`, res.status);
221
+ return;
222
+ }
223
+ const buf = Buffer.from(await res.arrayBuffer());
224
+ await fs.writeFile(zipPath, buf);
225
+
226
+ const staging = path.join(work, "staging");
227
+ await this.unzip(zipPath, staging);
228
+
229
+ const srcRoot = await this.locateSkillRoot(staging, 0);
230
+ if (!srcRoot) {
231
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包内未找到 SKILL.md,放弃覆盖`);
232
+ return;
233
+ }
234
+
235
+ // 守卫:下载包必须能解析出版本号,否则不做覆盖(服务端要求:无版本号不更新)。
236
+ let packageVersion: string | undefined;
237
+ try {
238
+ packageVersion = await readSkillVersion(srcRoot);
239
+ } catch (err) {
240
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} 读取下载包版本号失败,放弃覆盖`, err);
241
+ return;
242
+ }
243
+ if (!packageVersion) {
244
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} 下载包无版本号,放弃覆盖`);
245
+ return;
246
+ }
247
+ if (dl.sha256) {
248
+ const actualHash = skillIdentityHash(skillName, packageVersion);
249
+ if (actualHash !== dl.sha256) {
250
+ console.warn(`[skill-logger-plugin] ${skillName}@${version} 身份哈希不一致,放弃覆盖`);
251
+ return;
252
+ }
253
+ }
254
+
255
+ // [兼容处理] 缺少 .meta.json 时主动补齐
256
+ const metaPath = path.join(srcRoot, ".meta.json");
257
+ if (!(await this.exists(metaPath))) {
258
+ this.debug(`[skill-logger-plugin] 为 ${skillName}@${version} 自动生成 .meta.json`);
259
+ await fs.writeFile(metaPath, JSON.stringify({
260
+ ownerId: 'CMS_COMPAT',
261
+ slug: skillName,
262
+ version: packageVersion,
263
+ publishedAt: Date.now()
264
+ }, null, 2));
265
+ }
266
+
267
+ for (const target of targets) {
268
+ try {
269
+ await this.replaceDir(srcRoot, target);
270
+ this.debug(`已更新 ${skillName} → ${version} @ ${target}`);
271
+ } catch (err) {
272
+ console.warn(`[skill-logger-plugin] 覆盖 ${skillName} ${target} 失败`, err);
273
+ }
274
+ }
275
+ } finally {
276
+ await fs.rm(work, { recursive: true, force: true });
277
+ }
278
+ }
279
+
280
+ /** POST 下载接口取包 URL。返回 undefined 表示拿不到(调用方放弃本次更新)。 */
281
+ private async fetchDownloadUrl(
282
+ skillName: string,
283
+ version: string
284
+ ): Promise<{ url: string; version?: string; sha256?: string } | undefined> {
285
+ const cfg = this.getConfig();
286
+ const endpoint = cfg.platformBaseUrl!.replace(/\/$/, "") + "/skill_package/pull";
287
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
288
+ if (cfg.authToken) headers.Authorization = cfg.authToken;
289
+ const res = await this.fetchImpl(endpoint, {
290
+ method: "POST",
291
+ headers,
292
+ body: JSON.stringify({ skillName, version }),
293
+ });
294
+ if (!res.ok) {
295
+ console.warn(`[skill-logger-plugin] 取下载地址 ${skillName}@${version} 失败 HTTP`, res.status);
296
+ return undefined;
297
+ }
298
+ const data = (await res.json()) as { url?: unknown; downloadUrl?: unknown; version?: unknown; sha256?: unknown };
299
+ const url = typeof data?.url === "string" ? data.url : typeof data?.downloadUrl === "string" ? data.downloadUrl : undefined;
300
+ const resolvedVersion = typeof data?.version === "string" ? data.version : undefined;
301
+ const sha256 = typeof data?.sha256 === "string" ? data.sha256 : undefined;
302
+ if (!url) return undefined;
303
+ return { url, version: resolvedVersion, sha256 };
304
+ }
305
+
306
+ /** 在解压目录里定位含 SKILL.md 的目录(兼容包内是否带顶层目录)。深度上限 2。 */
307
+ private async locateSkillRoot(dir: string, depth: number): Promise<string | undefined> {
308
+ if (depth > 2) return undefined;
309
+ let entries;
310
+ try {
311
+ entries = await fs.readdir(dir, { withFileTypes: true });
312
+ } catch {
313
+ return undefined;
314
+ }
315
+ if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) return dir;
316
+ for (const e of entries) {
317
+ if (e.isDirectory()) {
318
+ const found = await this.locateSkillRoot(path.join(dir, e.name), depth + 1);
319
+ if (found) return found;
320
+ }
321
+ }
322
+ return undefined;
323
+ }
324
+
325
+ /**
326
+ * 直接覆盖、不备份,但保证「不丢数据」:
327
+ * 先把新内容暂存到同级临时目录 旧目录改名挪开 新内容 rename 换入 → 删掉挪开的旧目录。
328
+ * 换入失败时把旧目录还原,避免目标被清空。过程中的临时/旧目录都是隐藏且即时清理,不保留 .bak。
329
+ */
330
+ private async replaceDir(src: string, target: string): Promise<void> {
331
+ const parent = path.dirname(target);
332
+ await fs.mkdir(parent, { recursive: true });
333
+ const tag = `${Date.now()}-${randomUUID().slice(0, 8)}`;
334
+ const stage = path.join(parent, `.${path.basename(target)}.new-${tag}`);
335
+ const old = path.join(parent, `.${path.basename(target)}.old-${tag}`);
336
+
337
+ await fs.rm(stage, { recursive: true, force: true });
338
+ await fs.cp(src, stage, { recursive: true });
339
+
340
+ const hadTarget = await this.exists(target);
341
+ if (hadTarget) await fs.rename(target, old); // 旧目录先挪开(仍完整保留,便于回滚)
342
+ try {
343
+ await fs.rename(stage, target); // 新内容换入
344
+ } catch (err) {
345
+ // 换入失败:还原旧目录,清理暂存,确保目标不丢
346
+ if (hadTarget) await fs.rename(old, target).catch(() => {});
347
+ await fs.rm(stage, { recursive: true, force: true }).catch(() => {});
348
+ throw err;
349
+ }
350
+ await fs.rm(old, { recursive: true, force: true }).catch(() => {}); // 成功后清理,不留备份
351
+ }
352
+
353
+ private async exists(p: string): Promise<boolean> {
354
+ try {
355
+ await fs.access(p);
356
+ return true;
357
+ } catch {
358
+ return false;
359
+ }
360
+ }
361
+
362
+ /**
363
+ * 手动下发安装/更新指令 (供沙盒 HTTP Proxy 与星型架构中控使用)
364
+ */
365
+ /**
366
+ * URL 下载 ZIP 并直接解压替换到目标目录(不检查 SKILL.md)
367
+ */
368
+ async installZipFromUrl(url: string, targetDir: string): Promise<{success: boolean; message: string}> {
369
+ const work = path.join(this.tmpDir, `slp-zip-${randomUUID()}`);
370
+ await fs.mkdir(work, { recursive: true });
371
+ try {
372
+ const zipPath = path.join(work, "pkg.zip");
373
+ const res = await this.fetchImpl(url);
374
+ if (!res.ok) {
375
+ return { success: false, message: `下载失败: HTTP ${res.status}` };
376
+ }
377
+ const buf = Buffer.from(await res.arrayBuffer());
378
+ await fs.writeFile(zipPath, buf);
379
+
380
+ const staging = path.join(work, "staging");
381
+ await this.unzip(zipPath, staging);
382
+
383
+ await this.replaceDir(staging, targetDir);
384
+ return { success: true, message: `安装成功` };
385
+ } catch (err: any) {
386
+ return { success: false, message: `执行出错: ${err.message}` };
387
+ } finally {
388
+ await fs.rm(work, { recursive: true, force: true }).catch(() => {});
389
+ }
390
+ }
391
+
392
+ async manualInstall(options: {
393
+ code: string;
394
+ url?: string;
395
+ version?: string;
396
+ force: boolean;
397
+ targetDir: string;
398
+ additionalTargetDirs?: string[];
399
+ trace?: InstallTrace;
400
+ }): Promise<{ success: boolean; message: string }> {
401
+ const { code, force, targetDir, additionalTargetDirs = [], trace } = options;
402
+ let { url, version } = options;
403
+ const startedAt = Date.now();
404
+ let currentStage = "install.start";
405
+ let work: string | undefined;
406
+ const emit = (stage: string, data?: Record<string, unknown>) => {
407
+ currentStage = stage;
408
+ try {
409
+ trace?.(stage, data);
410
+ } catch {
411
+ // 诊断日志本身绝不能影响安装流程
412
+ }
413
+ };
414
+
415
+ emit("install.start", {
416
+ code,
417
+ version: version || "latest",
418
+ force,
419
+ hasDirectUrl: Boolean(url),
420
+ targetCount: 1 + additionalTargetDirs.length,
421
+ });
422
+
423
+ try {
424
+ if (!url) {
425
+ // 若没有直接传入 ZIP URL,向平台索取该 skill latest 下载地址
426
+ const lookupStartedAt = Date.now();
427
+ emit("download_url.lookup.start", { code, version: version || "latest" });
428
+ const dl = await this.fetchDownloadUrl(code, version || "latest");
429
+ if (!dl?.url) {
430
+ const message = `无法获取技能 ${code} 的下载地址`;
431
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
432
+ return { success: false, message };
433
+ }
434
+ url = dl.url;
435
+ version = dl.version || version;
436
+ emit("download_url.lookup.completed", {
437
+ version: version || "latest",
438
+ url,
439
+ elapsedMs: Date.now() - lookupStartedAt,
440
+ });
441
+ }
442
+
443
+ const targetSkillPath = path.join(targetDir, code);
444
+
445
+ // 如果非强制覆盖,且目标已经存在,则直接返回
446
+ if (!force && await this.exists(targetSkillPath)) {
447
+ const message = `技能 ${code} 已存在于目标目录`;
448
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
449
+ return { success: false, message };
450
+ }
451
+
452
+ work = path.join(this.tmpDir, `slp-manual-${randomUUID()}`);
453
+ await fs.mkdir(work, { recursive: true });
454
+
455
+ // 1. 下载 ZIP
456
+ const zipPath = path.join(work, "pkg.zip");
457
+ const downloadStartedAt = Date.now();
458
+ emit("download.request.start", { url });
459
+ const res = await this.fetchImpl(url);
460
+ emit("download.headers.received", {
461
+ status: res.status,
462
+ contentLength: res.headers?.get("content-length") || undefined,
463
+ contentType: res.headers?.get("content-type") || undefined,
464
+ elapsedMs: Date.now() - downloadStartedAt,
465
+ });
466
+ if (!res.ok) {
467
+ const message = `下载失败: HTTP ${res.status}`;
468
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
469
+ return { success: false, message };
470
+ }
471
+ const buf = Buffer.from(await res.arrayBuffer());
472
+ emit("download.body.completed", {
473
+ bytes: buf.byteLength,
474
+ elapsedMs: Date.now() - downloadStartedAt,
475
+ });
476
+ await fs.writeFile(zipPath, buf);
477
+ emit("download.file.written", { bytes: buf.byteLength });
478
+
479
+ // 2. 调用系统解压工具 unzip
480
+ const staging = path.join(work, "staging");
481
+ const unzipStartedAt = Date.now();
482
+ emit("unzip.start", { extractor: process.platform === "darwin" ? "ditto" : "unzip" });
483
+ await this.unzip(zipPath, staging);
484
+ emit("unzip.completed", { elapsedMs: Date.now() - unzipStartedAt });
485
+
486
+ // 3. 递归寻找真正的技能根目录(包含 SKILL.md 的一层)
487
+ const srcRoot = await this.locateSkillRoot(staging, 0);
488
+ if (!srcRoot) {
489
+ const message = `下载包内未找到 SKILL.md,非法的技能包结构`;
490
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
491
+ return { success: false, message };
492
+ }
493
+ emit("skill_root.located");
494
+
495
+ // [兼容处理] 缺少 .meta.json 时主动补齐
496
+ const metaPath = path.join(srcRoot, ".meta.json");
497
+ if (!(await this.exists(metaPath))) {
498
+ let parsedVersion = version || "unknown";
499
+ if (!version) {
500
+ try {
501
+ parsedVersion = (await readSkillVersion(srcRoot)) || "unknown";
502
+ } catch {}
503
+ }
504
+
505
+ this.debug(`[skill-logger-plugin] 为手动安装的 ${code}@${parsedVersion} 自动生成 .meta.json`);
506
+ await fs.writeFile(metaPath, JSON.stringify({
507
+ ownerId: 'CMS_COMPAT',
508
+ slug: code,
509
+ version: parsedVersion,
510
+ publishedAt: Date.now()
511
+ }, null, 2));
512
+ }
513
+
514
+ // 4. 同一份已解压内容依次原子替换用户目录及明确指定的附加目录
515
+ const targets = [targetDir, ...additionalTargetDirs];
516
+ for (let index = 0; index < targets.length; index += 1) {
517
+ const rootDir = targets[index];
518
+ const targetPath = path.join(rootDir, code);
519
+ const replaceStartedAt = Date.now();
520
+ emit("target.replace.start", { targetDir: rootDir, targetIndex: index, targetCount: targets.length });
521
+ await this.replaceDir(srcRoot, targetPath);
522
+ emit("target.replace.completed", {
523
+ targetDir: rootDir,
524
+ targetIndex: index,
525
+ targetCount: targets.length,
526
+ elapsedMs: Date.now() - replaceStartedAt,
527
+ });
528
+ }
529
+
530
+ emit("install.completed", { elapsedMs: Date.now() - startedAt, targetCount: targets.length });
531
+ return { success: true, message: `技能 ${code} 安装/更新成功` };
532
+ } catch (err: any) {
533
+ emit("install.failed", {
534
+ stage: currentStage,
535
+ errorName: err?.name,
536
+ message: err?.message || String(err),
537
+ stack: err?.stack,
538
+ elapsedMs: Date.now() - startedAt,
539
+ });
540
+ return { success: false, message: `执行出错: ${err.message}` };
541
+ } finally {
542
+ // 清理临时工作区
543
+ if (work) {
544
+ await fs.rm(work, { recursive: true, force: true }).catch(()=>{});
545
+ emit("cleanup.completed", { elapsedMs: Date.now() - startedAt });
546
+ }
547
+ }
548
+ }
549
+ }