@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/dist/index.js CHANGED
@@ -106,7 +106,6 @@ import fsSync from "node:fs";
106
106
  import path3 from "node:path";
107
107
  import os2 from "node:os";
108
108
  import { execFile } from "node:child_process";
109
- import { promisify } from "node:util";
110
109
  import { randomUUID, createHash } from "node:crypto";
111
110
 
112
111
  // src/skill-version.ts
@@ -139,14 +138,37 @@ function parseSkillVersion(content) {
139
138
  }
140
139
 
141
140
  // src/updater.ts
142
- var execFileAsync = promisify(execFile);
141
+ var EXTRACT_TIMEOUT_MS = 3e4;
143
142
  var ATTEMPT_COOLDOWN_MS = 30 * 60 * 1e3;
144
143
  function skillIdentityHash(code, version) {
145
144
  return createHash("sha256").update(`${code} ${code} ${version}`).digest("hex");
146
145
  }
146
+ function extractorCommandForPlatform(platform, zipPath, destDir) {
147
+ return platform === "darwin" ? { command: "ditto", args: ["-x", "-k", zipPath, destDir] } : { command: "unzip", args: ["-o", "-q", zipPath, "-d", destDir] };
148
+ }
149
+ function runExtractor(command, args) {
150
+ return new Promise((resolve, reject) => {
151
+ const child = execFile(command, args, {
152
+ encoding: "utf8",
153
+ timeout: EXTRACT_TIMEOUT_MS,
154
+ killSignal: "SIGKILL",
155
+ maxBuffer: 1024 * 1024
156
+ }, (error, _stdout, stderr) => {
157
+ if (!error) {
158
+ resolve();
159
+ return;
160
+ }
161
+ const detail = String(stderr || "").trim();
162
+ const timeout = error.killed ? `\uFF0C\u8D85\u8FC7 ${EXTRACT_TIMEOUT_MS}ms \u5DF2\u7EC8\u6B62` : "";
163
+ reject(new Error(`${command} \u89E3\u538B\u5931\u8D25${timeout}: ${detail || error.message}`));
164
+ });
165
+ child.stdin?.end();
166
+ });
167
+ }
147
168
  async function systemUnzip(zipPath, destDir) {
148
169
  await fs3.mkdir(destDir, { recursive: true });
149
- await execFileAsync("unzip", ["-o", "-q", zipPath, "-d", destDir]);
170
+ const { command, args } = extractorCommandForPlatform(process.platform, zipPath, destDir);
171
+ await runExtractor(command, args);
150
172
  }
151
173
  var SkillUpdater = class {
152
174
  getConfig;
@@ -395,36 +417,85 @@ var SkillUpdater = class {
395
417
  }
396
418
  }
397
419
  async manualInstall(options) {
398
- const { code, force, targetDir } = options;
420
+ const { code, force, targetDir, additionalTargetDirs = [], trace } = options;
399
421
  let { url, version } = options;
400
- if (!url) {
401
- const dl = await this.fetchDownloadUrl(code, version || "latest");
402
- if (!dl?.url) {
403
- return { success: false, message: `\u65E0\u6CD5\u83B7\u53D6\u6280\u80FD ${code} \u7684\u4E0B\u8F7D\u5730\u5740` };
422
+ const startedAt = Date.now();
423
+ let currentStage = "install.start";
424
+ let work;
425
+ const emit = (stage, data) => {
426
+ currentStage = stage;
427
+ try {
428
+ trace?.(stage, data);
429
+ } catch {
404
430
  }
405
- url = dl.url;
406
- version = dl.version || version;
407
- }
408
- const targetSkillPath = path3.join(targetDir, code);
409
- if (!force && await this.exists(targetSkillPath)) {
410
- return { success: false, message: `\u6280\u80FD ${code} \u5DF2\u5B58\u5728\u4E8E\u76EE\u6807\u76EE\u5F55` };
411
- }
412
- const work = path3.join(this.tmpDir, `slp-manual-${randomUUID()}`);
413
- await fs3.mkdir(work, { recursive: true });
431
+ };
432
+ emit("install.start", {
433
+ code,
434
+ version: version || "latest",
435
+ force,
436
+ hasDirectUrl: Boolean(url),
437
+ targetCount: 1 + additionalTargetDirs.length
438
+ });
414
439
  try {
440
+ if (!url) {
441
+ const lookupStartedAt = Date.now();
442
+ emit("download_url.lookup.start", { code, version: version || "latest" });
443
+ const dl = await this.fetchDownloadUrl(code, version || "latest");
444
+ if (!dl?.url) {
445
+ const message = `\u65E0\u6CD5\u83B7\u53D6\u6280\u80FD ${code} \u7684\u4E0B\u8F7D\u5730\u5740`;
446
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
447
+ return { success: false, message };
448
+ }
449
+ url = dl.url;
450
+ version = dl.version || version;
451
+ emit("download_url.lookup.completed", {
452
+ version: version || "latest",
453
+ url,
454
+ elapsedMs: Date.now() - lookupStartedAt
455
+ });
456
+ }
457
+ const targetSkillPath = path3.join(targetDir, code);
458
+ if (!force && await this.exists(targetSkillPath)) {
459
+ const message = `\u6280\u80FD ${code} \u5DF2\u5B58\u5728\u4E8E\u76EE\u6807\u76EE\u5F55`;
460
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
461
+ return { success: false, message };
462
+ }
463
+ work = path3.join(this.tmpDir, `slp-manual-${randomUUID()}`);
464
+ await fs3.mkdir(work, { recursive: true });
415
465
  const zipPath = path3.join(work, "pkg.zip");
466
+ const downloadStartedAt = Date.now();
467
+ emit("download.request.start", { url });
416
468
  const res = await this.fetchImpl(url);
469
+ emit("download.headers.received", {
470
+ status: res.status,
471
+ contentLength: res.headers?.get("content-length") || void 0,
472
+ contentType: res.headers?.get("content-type") || void 0,
473
+ elapsedMs: Date.now() - downloadStartedAt
474
+ });
417
475
  if (!res.ok) {
418
- return { success: false, message: `\u4E0B\u8F7D\u5931\u8D25: HTTP ${res.status}` };
476
+ const message = `\u4E0B\u8F7D\u5931\u8D25: HTTP ${res.status}`;
477
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
478
+ return { success: false, message };
419
479
  }
420
480
  const buf = Buffer.from(await res.arrayBuffer());
481
+ emit("download.body.completed", {
482
+ bytes: buf.byteLength,
483
+ elapsedMs: Date.now() - downloadStartedAt
484
+ });
421
485
  await fs3.writeFile(zipPath, buf);
486
+ emit("download.file.written", { bytes: buf.byteLength });
422
487
  const staging = path3.join(work, "staging");
488
+ const unzipStartedAt = Date.now();
489
+ emit("unzip.start", { extractor: process.platform === "darwin" ? "ditto" : "unzip" });
423
490
  await this.unzip(zipPath, staging);
491
+ emit("unzip.completed", { elapsedMs: Date.now() - unzipStartedAt });
424
492
  const srcRoot = await this.locateSkillRoot(staging, 0);
425
493
  if (!srcRoot) {
426
- return { success: false, message: `\u4E0B\u8F7D\u5305\u5185\u672A\u627E\u5230 SKILL.md\uFF0C\u975E\u6CD5\u7684\u6280\u80FD\u5305\u7ED3\u6784` };
494
+ const message = `\u4E0B\u8F7D\u5305\u5185\u672A\u627E\u5230 SKILL.md\uFF0C\u975E\u6CD5\u7684\u6280\u80FD\u5305\u7ED3\u6784`;
495
+ emit("install.failed", { stage: currentStage, message, elapsedMs: Date.now() - startedAt });
496
+ return { success: false, message };
427
497
  }
498
+ emit("skill_root.located");
428
499
  const metaPath = path3.join(srcRoot, ".meta.json");
429
500
  if (!await this.exists(metaPath)) {
430
501
  let parsedVersion = version || "unknown";
@@ -442,13 +513,37 @@ var SkillUpdater = class {
442
513
  publishedAt: Date.now()
443
514
  }, null, 2));
444
515
  }
445
- await this.replaceDir(srcRoot, targetSkillPath);
516
+ const targets = [targetDir, ...additionalTargetDirs];
517
+ for (let index = 0; index < targets.length; index += 1) {
518
+ const rootDir = targets[index];
519
+ const targetPath = path3.join(rootDir, code);
520
+ const replaceStartedAt = Date.now();
521
+ emit("target.replace.start", { targetDir: rootDir, targetIndex: index, targetCount: targets.length });
522
+ await this.replaceDir(srcRoot, targetPath);
523
+ emit("target.replace.completed", {
524
+ targetDir: rootDir,
525
+ targetIndex: index,
526
+ targetCount: targets.length,
527
+ elapsedMs: Date.now() - replaceStartedAt
528
+ });
529
+ }
530
+ emit("install.completed", { elapsedMs: Date.now() - startedAt, targetCount: targets.length });
446
531
  return { success: true, message: `\u6280\u80FD ${code} \u5B89\u88C5/\u66F4\u65B0\u6210\u529F` };
447
532
  } catch (err) {
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
+ });
448
540
  return { success: false, message: `\u6267\u884C\u51FA\u9519: ${err.message}` };
449
541
  } finally {
450
- await fs3.rm(work, { recursive: true, force: true }).catch(() => {
451
- });
542
+ if (work) {
543
+ await fs3.rm(work, { recursive: true, force: true }).catch(() => {
544
+ });
545
+ emit("cleanup.completed", { elapsedMs: Date.now() - startedAt });
546
+ }
452
547
  }
453
548
  }
454
549
  };
@@ -1281,11 +1376,11 @@ import path6 from "node:path";
1281
1376
  // src/identity.ts
1282
1377
  import os3 from "node:os";
1283
1378
  import { execFile as execFile2 } from "node:child_process";
1284
- import { promisify as promisify2 } from "node:util";
1285
- var execFileAsync2 = promisify2(execFile2);
1379
+ import { promisify } from "node:util";
1380
+ var execFileAsync = promisify(execFile2);
1286
1381
  async function getGitConfigValue(key) {
1287
1382
  try {
1288
- const { stdout } = await execFileAsync2("git", ["config", "--global", key]);
1383
+ const { stdout } = await execFileAsync("git", ["config", "--global", key]);
1289
1384
  return stdout.trim();
1290
1385
  } catch {
1291
1386
  console.warn(
@@ -1562,6 +1657,9 @@ function normalizeAssistantUserId(userId) {
1562
1657
  if (!ASSISTANT_WORKSPACE_ID_RE.test(pureId)) return void 0;
1563
1658
  return pureId;
1564
1659
  }
1660
+ function shouldSyncBuiltInTemplate(action, isBuiltIn) {
1661
+ return action === "UPDATE_SKILL" && isBuiltIn === true;
1662
+ }
1565
1663
  var GatewayWsClient = class {
1566
1664
  ws = null;
1567
1665
  options;
@@ -1598,6 +1696,14 @@ var GatewayWsClient = class {
1598
1696
  } catch (e) {
1599
1697
  }
1600
1698
  }
1699
+ createInstallTrace(context) {
1700
+ return (stage, data) => {
1701
+ this.appendLogToFile(stage === "install.failed" ? "ERROR" : "INFO", "Install", stage, {
1702
+ ...context,
1703
+ ...data
1704
+ });
1705
+ };
1706
+ }
1601
1707
  constructor(options) {
1602
1708
  this.options = options;
1603
1709
  }
@@ -1849,8 +1955,16 @@ var GatewayWsClient = class {
1849
1955
  * 核心指令分发中心:完全跳过沙盒,基于 userId 直接进行底层物理文件操作
1850
1956
  */
1851
1957
  async handleMessage(msg) {
1852
- const { action, userId, code, url, force, version, replyId } = msg;
1853
- this.appendLogToFile("INFO", "Command", `Received WS message`, { action, userId, code, version, replyId });
1958
+ const { action, userId, code, url, force, version, replyId, isBuiltIn } = msg;
1959
+ this.appendLogToFile("INFO", "Command", `Received WS message`, {
1960
+ action,
1961
+ userId,
1962
+ code,
1963
+ version,
1964
+ replyId,
1965
+ isBuiltIn,
1966
+ hasDirectUrl: Boolean(url)
1967
+ });
1854
1968
  if (!action || !userId) {
1855
1969
  this.appendLogToFile("WARN", "Command", `Message dropped: missing action or userId`, msg);
1856
1970
  return;
@@ -1872,7 +1986,8 @@ var GatewayWsClient = class {
1872
1986
  url,
1873
1987
  version,
1874
1988
  force: force !== false,
1875
- targetDir
1989
+ targetDir,
1990
+ trace: this.createInstallTrace({ action, replyId, userId, code: safeCode })
1876
1991
  });
1877
1992
  this.reply(replyId, { success: result.success, message: result.message, action });
1878
1993
  } else if (action === "UNINSTALL_SKILL") {
@@ -1907,7 +2022,7 @@ var GatewayWsClient = class {
1907
2022
  }
1908
2023
  const metaPath = path7.join(skillDir, ".meta.json");
1909
2024
  let isPlatform = false;
1910
- let isBuiltIn = e.isSymbolicLink();
2025
+ let isBuiltIn2 = e.isSymbolicLink();
1911
2026
  let metaData = null;
1912
2027
  let name = e.name;
1913
2028
  let description = "";
@@ -1928,7 +2043,7 @@ var GatewayWsClient = class {
1928
2043
  const parsed = JSON.parse(metaContent);
1929
2044
  if (parsed) {
1930
2045
  if (parsed.ownerId === "CMS" || parsed.ownerId === "CMS_COMPAT") isPlatform = true;
1931
- if (parsed.isBuiltIn === true || parsed.ownerId === "built-in") isBuiltIn = true;
2046
+ if (parsed.isBuiltIn === true || parsed.ownerId === "built-in") isBuiltIn2 = true;
1932
2047
  metaData = parsed;
1933
2048
  }
1934
2049
  } catch (err) {
@@ -1939,7 +2054,7 @@ var GatewayWsClient = class {
1939
2054
  list.push({
1940
2055
  code: e.name,
1941
2056
  isPlatform: true,
1942
- isBuiltIn,
2057
+ isBuiltIn: isBuiltIn2,
1943
2058
  version: skillVersion,
1944
2059
  name,
1945
2060
  description,
@@ -1949,7 +2064,7 @@ var GatewayWsClient = class {
1949
2064
  list.push({
1950
2065
  code: e.name,
1951
2066
  isPlatform: false,
1952
- isBuiltIn,
2067
+ isBuiltIn: isBuiltIn2,
1953
2068
  version: skillVersion,
1954
2069
  name,
1955
2070
  description
@@ -1960,21 +2075,54 @@ var GatewayWsClient = class {
1960
2075
  } else if (action === "UPDATE_SKILL") {
1961
2076
  if (!safeCode) throw new Error("Missing code parameter");
1962
2077
  const delayMs = Math.random() * 5e3;
2078
+ const syncBuiltInTemplate = shouldSyncBuiltInTemplate(action, isBuiltIn);
1963
2079
  console.log(`[skill-logger-plugin][WS] Scheduled UPDATE for user ${userId}, code: ${safeCode} in ${Math.round(delayMs)}ms`);
1964
- this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, { userId, code: safeCode, version, delayMs: Math.round(delayMs) });
2080
+ this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, {
2081
+ userId,
2082
+ code: safeCode,
2083
+ version,
2084
+ isBuiltIn,
2085
+ syncBuiltInTemplate,
2086
+ delayMs: Math.round(delayMs)
2087
+ });
1965
2088
  setTimeout(async () => {
1966
2089
  try {
1967
- await this.options.updater.manualInstall({
2090
+ const additionalTargetDirs = syncBuiltInTemplate ? [path7.join(openclawHome(), "workspace-xgjk-assistant-template", "skills")] : [];
2091
+ const result = await this.options.updater.manualInstall({
1968
2092
  code: safeCode,
1969
2093
  url,
1970
2094
  version,
1971
2095
  force: true,
1972
- targetDir
2096
+ targetDir,
2097
+ additionalTargetDirs,
2098
+ trace: this.createInstallTrace({
2099
+ action,
2100
+ replyId,
2101
+ userId,
2102
+ code: safeCode,
2103
+ isBuiltIn,
2104
+ syncBuiltInTemplate
2105
+ })
2106
+ });
2107
+ this.appendLogToFile(result.success ? "INFO" : "ERROR", "Command", `UPDATE_SKILL completed`, {
2108
+ userId,
2109
+ code: safeCode,
2110
+ replyId,
2111
+ success: result.success,
2112
+ message: result.message,
2113
+ syncBuiltInTemplate
1973
2114
  });
1974
2115
  if (replyId) {
1975
- this.reply(replyId, { success: true, message: `Skill ${safeCode} updated successfully`, action });
2116
+ this.reply(replyId, { success: result.success, message: result.message, action });
1976
2117
  }
1977
2118
  } catch (e) {
2119
+ this.appendLogToFile("ERROR", "Command", `UPDATE_SKILL threw`, {
2120
+ userId,
2121
+ code: safeCode,
2122
+ replyId,
2123
+ message: e?.message || String(e),
2124
+ stack: e?.stack
2125
+ });
1978
2126
  if (replyId) this.reply(replyId, { success: false, message: e.message, action });
1979
2127
  }
1980
2128
  }, delayMs);
@@ -1985,11 +2133,25 @@ var GatewayWsClient = class {
1985
2133
  this.appendLogToFile("INFO", "Command", `INSTALL_EXPERT received`, { userId, code: safeCode, version: version2, downloadUrl });
1986
2134
  const userSkillRoot = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user");
1987
2135
  const expertTarget = path7.join(userSkillRoot, "experts", safeCode);
1988
- await fs6.mkdir(path7.dirname(expertTarget), { recursive: true });
1989
- const expertResult = await this.options.updater.installZipFromUrl(downloadUrl, expertTarget);
1990
- if (!expertResult.success) {
1991
- throw new Error(`\u4E13\u5BB6\u5B89\u88C5\u5931\u8D25: ${expertResult.message}`);
2136
+ let skipInstall = false;
2137
+ const metaPath = path7.join(expertTarget, ".meta.json");
2138
+ try {
2139
+ const raw = await fs6.readFile(metaPath, "utf-8");
2140
+ const existing = JSON.parse(raw);
2141
+ if (existing.version && existing.version === (version2 || "1.0.0")) {
2142
+ skipInstall = true;
2143
+ }
2144
+ } catch {
2145
+ }
2146
+ if (!skipInstall) {
2147
+ await fs6.mkdir(path7.dirname(expertTarget), { recursive: true });
2148
+ const expertResult = await this.options.updater.installZipFromUrl(downloadUrl, expertTarget);
2149
+ if (!expertResult.success) {
2150
+ throw new Error(`\u4E13\u5BB6\u5B89\u88C5\u5931\u8D25: ${expertResult.message}`);
2151
+ }
1992
2152
  }
2153
+ const meta = { code: safeCode, name, version: version2 || "1.0.0", installedAt: Date.now() };
2154
+ await fs6.writeFile(metaPath, JSON.stringify(meta, null, 2));
1993
2155
  const skillTargetRoot = path7.join(userSkillRoot, "skills");
1994
2156
  await fs6.mkdir(skillTargetRoot, { recursive: true });
1995
2157
  const skillResults = [];
@@ -2014,48 +2176,42 @@ var GatewayWsClient = class {
2014
2176
  action,
2015
2177
  data: { expertCode: safeCode, skills: skillResults }
2016
2178
  });
2017
- } else if (action === "GET_EXPERT_REGISTRY") {
2018
- console.log(`[skill-logger-plugin][WS] Executing GET_EXPERT_REGISTRY for user ${userId}`);
2019
- this.appendLogToFile("INFO", "Command", `GET_EXPERT_REGISTRY received`, { userId });
2020
- const registryFilePath = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "expert-registry.yaml");
2021
- let content = "";
2022
- let fileExists = false;
2179
+ } else if (action === "UNINSTALL_EXPERT") {
2180
+ if (!safeCode) throw new Error("Missing code parameter");
2181
+ console.log(`[skill-logger-plugin][WS] Executing UNINSTALL_EXPERT for user ${userId}, code: ${safeCode}`);
2182
+ this.appendLogToFile("INFO", "Command", `UNINSTALL_EXPERT received`, { userId, code: safeCode });
2183
+ const expertPath = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "experts", safeCode);
2184
+ await fs6.rm(expertPath, { recursive: true, force: true });
2185
+ this.reply(replyId, { success: true, message: `\u4E13\u5BB6 ${safeCode} \u5DF2\u5378\u8F7D`, action });
2186
+ } else if (action === "LIST_EXPERTS") {
2187
+ console.log(`[skill-logger-plugin][WS] Executing LIST_EXPERTS for user ${userId}`);
2188
+ this.appendLogToFile("INFO", "Command", `LIST_EXPERTS received`, { userId });
2189
+ const expertsDir = path7.join(openclawHome(), `workspace-assistant-${pureId}`, ".user", "experts");
2190
+ const list = [];
2023
2191
  try {
2024
- const stat = await fs6.stat(registryFilePath);
2025
- fileExists = stat.isFile();
2026
- } catch (err) {
2027
- if (err?.code !== "ENOENT") throw err;
2028
- }
2029
- if (fileExists) {
2030
- content = await fs6.readFile(registryFilePath, "utf8");
2031
- const expertsList = [];
2032
- const lines = content.split("\n");
2033
- let currentExpert = null;
2034
- for (const line of lines) {
2035
- const trimmed = line.trim();
2036
- if (trimmed.startsWith("#")) continue;
2037
- const idMatch = line.match(/^\s*-\s*id:\s*(.+)$/);
2038
- if (idMatch) {
2039
- if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
2040
- currentExpert = { id: idMatch[1].trim() };
2041
- continue;
2042
- }
2043
- if (currentExpert) {
2044
- const nameMatch = line.match(/^\s*name:\s*(.+)$/);
2045
- if (nameMatch) {
2046
- currentExpert.name = nameMatch[1].trim();
2047
- }
2048
- const descMatch = line.match(/^\s*description:\s*(.+)$/);
2049
- if (descMatch) {
2050
- currentExpert.description = descMatch[1].trim();
2192
+ const stat = await fs6.stat(expertsDir);
2193
+ if (stat.isDirectory()) {
2194
+ const entries = await fs6.readdir(expertsDir, { withFileTypes: true });
2195
+ for (const e of entries) {
2196
+ if (!e.isDirectory()) continue;
2197
+ const metaPath = path7.join(expertsDir, e.name, ".meta.json");
2198
+ try {
2199
+ const raw = await fs6.readFile(metaPath, "utf-8");
2200
+ const meta = JSON.parse(raw);
2201
+ list.push({
2202
+ code: meta.code || e.name,
2203
+ name: meta.name || e.name,
2204
+ version: meta.version || "",
2205
+ installedAt: meta.installedAt
2206
+ });
2207
+ } catch {
2208
+ list.push({ code: e.name, name: e.name, version: "" });
2051
2209
  }
2052
2210
  }
2053
2211
  }
2054
- if (currentExpert && currentExpert.id) expertsList.push(currentExpert);
2055
- this.reply(replyId, { success: true, data: expertsList, action });
2056
- } else {
2057
- this.reply(replyId, { success: false, message: `\u672A\u627E\u5230\u7528\u6237\u6280\u80FD\u914D\u7F6E\u6587\u4EF6\uFF0C\u53EF\u80FD\u5C1A\u672A\u6CE8\u518C\u6216\u6587\u4EF6\u5DF2\u4E22\u5931`, action });
2212
+ } catch {
2058
2213
  }
2214
+ this.reply(replyId, { success: true, data: list, action });
2059
2215
  } else {
2060
2216
  console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
2061
2217
  this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
@@ -2072,7 +2228,13 @@ var GatewayWsClient = class {
2072
2228
  this.appendLogToFile("WARN", "Command", `Cannot reply: WS closed or missing replyId`, { replyId });
2073
2229
  return;
2074
2230
  }
2075
- this.ws.send(JSON.stringify({ type: "REPLY", replyId, ...payload }));
2231
+ this.ws.send(JSON.stringify({ type: "REPLY", replyId, ...payload }), (err) => {
2232
+ if (err) {
2233
+ this.appendLogToFile("ERROR", "Command", `Reply send failed`, { replyId, message: err.message });
2234
+ return;
2235
+ }
2236
+ this.appendLogToFile("INFO", "Command", `Reply sent`, { replyId });
2237
+ });
2076
2238
  }
2077
2239
  destroy() {
2078
2240
  this.isDestroyed = true;
@@ -0,0 +1,39 @@
1
+ import { describe, it, before } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ let isSkillMdReadPath;
4
+ let extractApiPluginConfig;
5
+ describe("isSkillMdReadPath", () => {
6
+ before(async () => {
7
+ ({ isSkillMdReadPath, extractApiPluginConfig } = await import("./index.ts"));
8
+ });
9
+ it("POSIX 路径 basename 为 SKILL.md 时为 true", () => {
10
+ assert.equal(isSkillMdReadPath("/x/y/my-skill/SKILL.md"), true);
11
+ });
12
+ it("Windows 风格路径", { skip: process.platform !== "win32" }, () => {
13
+ assert.equal(isSkillMdReadPath(String.raw `C:\x\y\my-skill\SKILL.md`), true);
14
+ });
15
+ it("fooSKILL.md / my-SKILL.md 不误判", () => {
16
+ assert.equal(isSkillMdReadPath("/tmp/fooSKILL.md"), false);
17
+ assert.equal(isSkillMdReadPath("/tmp/my-SKILL.md"), false);
18
+ });
19
+ });
20
+ describe("extractApiPluginConfig", () => {
21
+ it("从 OpenClaw 注入的 api.pluginConfig 初始化运行期配置", () => {
22
+ assert.deepEqual(extractApiPluginConfig({
23
+ pluginConfig: {
24
+ platformBaseUrl: "https://platform.example/api",
25
+ reportBaseUrl: "https://report.example/api",
26
+ authToken: "Bearer token",
27
+ recordUnattributed: true,
28
+ },
29
+ }), {
30
+ platformBaseUrl: "https://platform.example/api",
31
+ reportBaseUrl: "https://report.example/api",
32
+ authToken: "Bearer token",
33
+ recordUnattributed: true,
34
+ });
35
+ });
36
+ it("未配置时返回空对象", () => {
37
+ assert.deepEqual(extractApiPluginConfig({}), {});
38
+ });
39
+ });
@@ -0,0 +1,102 @@
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 { ConfigSync } from "./config-sync.ts";
8
+ import { SkillUpdater } from "./updater.ts";
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-int-${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
+ describe("版本更新闭环(端到端)", () => {
19
+ it("config-pull 报最新版 → 检测落后 → 下载覆盖 → 再查收敛", async () => {
20
+ // 本地装一个 demo@1.0.0
21
+ const ws = path.join(dir, "workspace");
22
+ const skillDir = path.join(ws, "skills", "demo");
23
+ await fs.mkdir(skillDir, { recursive: true });
24
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
25
+ const config = { platformBaseUrl: "https://api", autoUpdateSkills: true };
26
+ // 服务端最新版本(随测试推进而变化),模拟「更新后平台版本=本地版本」的收敛。
27
+ let platformLatest = "2.0.0";
28
+ // ConfigSync 的 fetch:/skill_config/pull → 带 latestVersion
29
+ const csFetch = async (_url, _init) => ({
30
+ ok: true,
31
+ status: 200,
32
+ json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: platformLatest, functions: [] }] }),
33
+ });
34
+ // Updater 的 fetch:/skill_package/pull → {url, sha256};GET → zip 字节
35
+ const upFetch = async (_url, init) => {
36
+ if (init?.method === "POST") {
37
+ return {
38
+ ok: true,
39
+ status: 200,
40
+ json: async () => ({ url: "https://pkg/skill.zip", version: platformLatest, sha256: idHash("demo", platformLatest) }),
41
+ arrayBuffer: async () => new ArrayBuffer(0),
42
+ };
43
+ }
44
+ return { ok: true, status: 200, json: async () => ({}), arrayBuffer: async () => new Uint8Array([1]).buffer };
45
+ };
46
+ // 解压:写出新版本的 SKILL.md(版本号与 platformLatest 一致)
47
+ const unzip = async (_zip, destDir) => {
48
+ const root = path.join(destDir, "demo");
49
+ await fs.mkdir(root, { recursive: true });
50
+ await fs.writeFile(path.join(root, "SKILL.md"), `---\nname: demo\nversion: ${platformLatest}\n---\nNEW\n`);
51
+ };
52
+ const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip, tmpDir: dir });
53
+ const cs = new ConfigSync({
54
+ paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
55
+ getConfig: () => config,
56
+ fetchImpl: csFetch,
57
+ resolveSkillDirs: () => [path.join(ws, "skills")],
58
+ updater,
59
+ });
60
+ // 第一轮:检测落后并覆盖到 2.0.0
61
+ await cs.checkVersionsAndUpdate();
62
+ const afterMd = await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8");
63
+ assert.ok(afterMd.includes("version: 2.0.0"), "应被更新到 2.0.0");
64
+ assert.ok(afterMd.includes("NEW"), "应为新内容");
65
+ // 第二轮:平台版本仍 2.0.0、本地已 2.0.0 → 收敛,无落后副本
66
+ platformLatest = "2.0.0";
67
+ await cs.checkVersionsAndUpdate();
68
+ assert.equal(cs.detectOutdated().length, 0, "更新后应收敛");
69
+ // sync.json 落了 installations 映射,且版本已是 2.0.0
70
+ const state = JSON.parse(await fs.readFile(path.join(dir, "sync.json"), "utf-8"));
71
+ assert.equal(state.installations.demo[0].version, "2.0.0");
72
+ });
73
+ it("autoUpdateSkills=false:检测到落后也不覆盖", async () => {
74
+ const ws = path.join(dir, "workspace");
75
+ const skillDir = path.join(ws, "skills", "demo");
76
+ await fs.mkdir(skillDir, { recursive: true });
77
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "---\nname: demo\nversion: 1.0.0\n---\nOLD\n");
78
+ const config = { platformBaseUrl: "https://api", autoUpdateSkills: false };
79
+ const csFetch = async () => ({
80
+ ok: true,
81
+ status: 200,
82
+ json: async () => ({ configs: [{ skillName: "demo", version: "1.0.0", latestVersion: "2.0.0", functions: [] }] }),
83
+ });
84
+ let upCalled = 0;
85
+ const upFetch = async () => {
86
+ upCalled++;
87
+ return { ok: true, status: 200, json: async () => ({ url: "" }), arrayBuffer: async () => new ArrayBuffer(0) };
88
+ };
89
+ const updater = new SkillUpdater({ getConfig: () => config, fetchImpl: upFetch, unzip: async () => { }, tmpDir: dir });
90
+ const cs = new ConfigSync({
91
+ paths: { extensionsDir: path.join(dir, "ext"), syncStatePath: path.join(dir, "sync.json"), openclawConfigPath: path.join(dir, "openclaw.json") },
92
+ getConfig: () => config,
93
+ fetchImpl: csFetch,
94
+ resolveSkillDirs: () => [path.join(ws, "skills")],
95
+ updater,
96
+ });
97
+ await cs.checkVersionsAndUpdate();
98
+ assert.equal(cs.detectOutdated().length, 1, "仍检测到落后");
99
+ assert.equal(upCalled, 0, "关闭时不应发起下载");
100
+ assert.ok((await fs.readFile(path.join(skillDir, "SKILL.md"), "utf-8")).includes("OLD"), "文件保持原样");
101
+ });
102
+ });